fiber_audit 0.2.1 → 0.3.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.
@@ -6,15 +6,25 @@ module FiberAudit
6
6
  module Subprocess
7
7
  module KernelInstanceHook
8
8
  def system(...)
9
- Registry.observe(operation: 'Kernel.system') { super }
9
+ Registry.observe(
10
+ operation: 'Kernel.system',
11
+ measurements: { waits_for_child: true }
12
+ ) { super }
10
13
  end
11
14
 
12
15
  def exec(...)
13
- Registry.observe(operation: 'Kernel.exec', emit_start: true) { super }
16
+ Registry.observe(
17
+ operation: 'Kernel.exec',
18
+ measurements: { replaces_process: true },
19
+ emit_start: true
20
+ ) { super }
14
21
  end
15
22
 
16
23
  def spawn(...)
17
- Registry.observe(operation: 'Kernel.spawn') { super }
24
+ Registry.observe(
25
+ operation: 'Kernel.spawn',
26
+ measurements: { returns_pid: true, does_not_wait: true }
27
+ ) { super }
18
28
  end
19
29
 
20
30
  private :system, :exec, :spawn
@@ -22,15 +32,25 @@ module FiberAudit
22
32
 
23
33
  module KernelSingletonHook
24
34
  def system(...)
25
- Registry.observe(operation: 'Kernel.system') { super }
35
+ Registry.observe(
36
+ operation: 'Kernel.system',
37
+ measurements: { waits_for_child: true }
38
+ ) { super }
26
39
  end
27
40
 
28
41
  def exec(...)
29
- Registry.observe(operation: 'Kernel.exec', emit_start: true) { super }
42
+ Registry.observe(
43
+ operation: 'Kernel.exec',
44
+ measurements: { replaces_process: true },
45
+ emit_start: true
46
+ ) { super }
30
47
  end
31
48
 
32
49
  def spawn(...)
33
- Registry.observe(operation: 'Kernel.spawn') { super }
50
+ Registry.observe(
51
+ operation: 'Kernel.spawn',
52
+ measurements: { returns_pid: true, does_not_wait: true }
53
+ ) { super }
34
54
  end
35
55
  end
36
56
 
@@ -41,12 +61,70 @@ module FiberAudit
41
61
  end
42
62
 
43
63
  module ProcessHook
64
+ def spawn(...)
65
+ Registry.observe(
66
+ operation: 'Process.spawn',
67
+ measurements: { returns_pid: true, does_not_wait: true }
68
+ ) { super }
69
+ end
70
+
71
+ def exec(...)
72
+ Registry.observe(
73
+ operation: 'Process.exec',
74
+ measurements: { replaces_process: true },
75
+ emit_start: true
76
+ ) { super }
77
+ end
78
+
79
+ def wait(...)
80
+ Registry.observe(
81
+ operation: 'Process.wait',
82
+ measurements: { waits_for_child: true }
83
+ ) { super }
84
+ end
85
+
86
+ def wait2(...)
87
+ Registry.observe(
88
+ operation: 'Process.wait2',
89
+ measurements: { waits_for_child: true }
90
+ ) { super }
91
+ end
92
+
93
+ def waitpid(...)
94
+ Registry.observe(
95
+ operation: 'Process.waitpid',
96
+ measurements: { waits_for_child: true }
97
+ ) { super }
98
+ end
99
+
100
+ def waitpid2(...)
101
+ Registry.observe(
102
+ operation: 'Process.waitpid2',
103
+ measurements: { waits_for_child: true }
104
+ ) { super }
105
+ end
106
+
44
107
  def waitall(...)
45
- Registry.observe(operation: 'Process.waitall') { super }
108
+ Registry.observe(
109
+ operation: 'Process.waitall',
110
+ measurements: { waits_for_child: true }
111
+ ) { super }
46
112
  end
47
113
 
48
114
  def detach(...)
49
- Registry.observe(operation: 'Process.detach') { super }
115
+ Registry.observe(
116
+ operation: 'Process.detach',
117
+ measurements: { returns_thread: true, does_not_wait: true }
118
+ ) { super }
119
+ end
120
+ end
121
+
122
+ module ProcessStatusHook
123
+ def wait(...)
124
+ Registry.observe(
125
+ operation: 'Process::Status.wait',
126
+ measurements: { waits_for_child: true }
127
+ ) { super }
50
128
  end
51
129
  end
52
130
 
@@ -75,6 +153,9 @@ module FiberAudit
75
153
  registry.prepend_once(Kernel.singleton_class, KernelSingletonHook)
76
154
  registry.prepend_once(IO.singleton_class, IOHook)
77
155
  registry.prepend_once(Process.singleton_class, ProcessHook)
156
+ if defined?(Process::Status) && Process::Status.respond_to?(:wait)
157
+ registry.prepend_once(Process::Status.singleton_class, ProcessStatusHook)
158
+ end
78
159
  registry.prepend_once(Open3.singleton_class, Open3Hook) if defined?(Open3)
79
160
  end
80
161
  end
@@ -12,20 +12,6 @@ module FiberAudit
12
12
  def thread_variable_set(...)
13
13
  Registry.observe(operation: 'Thread.thread_variable_set') { super }
14
14
  end
15
-
16
- def [](...)
17
- return super unless equal?(Thread.current)
18
-
19
- Registry.observe(operation: 'Thread.current.[]') { super }
20
- end
21
-
22
- def []=(...)
23
- if equal?(Thread.current)
24
- Registry.observe(operation: 'Thread.current.[]=') { super }
25
- else
26
- super
27
- end
28
- end
29
15
  end
30
16
 
31
17
  module_function
@@ -10,14 +10,14 @@ module FiberAudit
10
10
  # Ruby's scheduler API fixes this writer-like method name.
11
11
  # rubocop:disable Naming/AccessorMethodName
12
12
  def set_scheduler(scheduler)
13
- FiberAudit::Runtime::SchedulerObserver.scheduler_replacing(thread: Thread.current) if Fiber.scheduler
13
+ previous = Fiber.scheduler
14
14
  result = super
15
- if scheduler
16
- FiberAudit::Runtime::SchedulerObserver.scheduler_installed(
17
- scheduler: scheduler,
18
- thread: Thread.current
19
- )
20
- end
15
+ current = Fiber.scheduler
16
+ FiberAudit::Runtime::SchedulerObserver.scheduler_changed(
17
+ previous: previous,
18
+ current: current,
19
+ thread: Thread.current
20
+ )
21
21
  result
22
22
  end
23
23
  # rubocop:enable Naming/AccessorMethodName
@@ -25,7 +25,10 @@ module FiberAudit
25
25
 
26
26
  module SchedulerCloseHook
27
27
  def close(...)
28
- FiberAudit::Runtime::SchedulerObserver.scheduler_closing(thread: Thread.current)
28
+ FiberAudit::Runtime::SchedulerObserver.scheduler_closing(
29
+ scheduler: self,
30
+ thread: Thread.current
31
+ )
29
32
  super
30
33
  end
31
34
  end
@@ -41,19 +44,15 @@ module FiberAudit
41
44
  observer
42
45
  end
43
46
 
44
- def scheduler_replacing(thread:)
45
- current_for_process&.scheduler_closing(thread: thread)
46
- end
47
-
48
- def scheduler_installed(scheduler:, thread:)
47
+ def scheduler_changed(previous:, current:, thread:)
49
48
  observer = current_for_process
50
49
  return unless observer
51
50
 
52
- observer.scheduler_installed(scheduler: scheduler, thread: thread)
51
+ observer.scheduler_changed(previous: previous, current: current, thread: thread)
53
52
  end
54
53
 
55
- def scheduler_closing(thread:)
56
- current_for_process&.scheduler_closing(thread: thread)
54
+ def scheduler_closing(thread:, scheduler: nil)
55
+ current_for_process&.scheduler_closing(scheduler: scheduler, thread: thread)
57
56
  end
58
57
 
59
58
  def deactivate(observer)
@@ -82,6 +81,8 @@ module FiberAudit
82
81
  @watchdog = watchdog
83
82
  @owner_pid = Process.pid
84
83
  @active = true
84
+ @mutex = Mutex.new
85
+ @schedulers = {}
85
86
  end
86
87
 
87
88
  def active_for_current_process?
@@ -94,8 +95,17 @@ module FiberAudit
94
95
  self
95
96
  end
96
97
 
98
+ def scheduler_changed(previous:, current:, thread:)
99
+ return self unless active_for_current_process?
100
+
101
+ scheduler_closing(scheduler: previous, thread: thread) if previous && !previous.equal?(current)
102
+ scheduler_installed(scheduler: current, thread: thread) if current
103
+ self
104
+ end
105
+
97
106
  def scheduler_installed(scheduler:, thread:)
98
107
  return self unless active_for_current_process? && watchdog.enabled?
108
+ return self unless track_scheduler(scheduler, thread)
99
109
 
100
110
  install_close_hook!(scheduler)
101
111
  watchdog.scheduler_installed(thread: thread)
@@ -107,13 +117,17 @@ module FiberAudit
107
117
  self
108
118
  end
109
119
 
110
- def scheduler_closing(thread:)
111
- watchdog.scheduler_closing(thread: thread) if active_for_current_process?
120
+ def scheduler_closing(thread:, scheduler: nil)
121
+ return self unless active_for_current_process?
122
+ return self unless untrack_scheduler(scheduler, thread)
123
+
124
+ watchdog.scheduler_closing(thread: thread)
112
125
  self
113
126
  end
114
127
 
115
128
  def deactivate
116
129
  @active = false
130
+ @mutex.synchronize { @schedulers.clear }
117
131
  self
118
132
  end
119
133
 
@@ -123,6 +137,28 @@ module FiberAudit
123
137
  singleton = scheduler.singleton_class
124
138
  singleton.prepend(SchedulerCloseHook) unless singleton.ancestors.include?(SchedulerCloseHook)
125
139
  end
140
+
141
+ def track_scheduler(scheduler, thread)
142
+ @mutex.synchronize do
143
+ key = thread.object_id
144
+ return false if @schedulers[key].equal?(scheduler)
145
+
146
+ @schedulers[key] = scheduler
147
+ true
148
+ end
149
+ end
150
+
151
+ def untrack_scheduler(scheduler, thread)
152
+ @mutex.synchronize do
153
+ key = thread.object_id
154
+ tracked = @schedulers[key]
155
+ return false unless tracked
156
+ return false if scheduler && !tracked.equal?(scheduler)
157
+
158
+ @schedulers.delete(key)
159
+ true
160
+ end
161
+ end
126
162
  end
127
163
  end
128
164
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FiberAudit
4
+ module Runtime
5
+ # Immutable scheduler snapshot/context fields captured at operation start.
6
+ # All fields are Boolean or nil only, as per JSONL 1.0 schema requirements.
7
+ SchedulerSnapshot = Data.define(
8
+ :scheduler_present,
9
+ :fiber_blocking,
10
+ :scheduler_io_select_supported,
11
+ :scheduler_process_wait_supported,
12
+ :scheduler_address_resolve_supported
13
+ ) do
14
+ def initialize(
15
+ scheduler_present:,
16
+ fiber_blocking:,
17
+ scheduler_io_select_supported: nil,
18
+ scheduler_process_wait_supported: nil,
19
+ scheduler_address_resolve_supported: nil
20
+ )
21
+ super(
22
+ scheduler_present: normalize_boolean(scheduler_present, 'scheduler_present'),
23
+ fiber_blocking: normalize_boolean(fiber_blocking, 'fiber_blocking'),
24
+ scheduler_io_select_supported: normalize_optional_boolean(
25
+ scheduler_io_select_supported,
26
+ 'scheduler_io_select_supported'
27
+ ),
28
+ scheduler_process_wait_supported: normalize_optional_boolean(
29
+ scheduler_process_wait_supported,
30
+ 'scheduler_process_wait_supported'
31
+ ),
32
+ scheduler_address_resolve_supported: normalize_optional_boolean(
33
+ scheduler_address_resolve_supported,
34
+ 'scheduler_address_resolve_supported'
35
+ )
36
+ )
37
+ end
38
+
39
+ def to_measurements
40
+ {
41
+ scheduler_present: scheduler_present,
42
+ fiber_blocking: fiber_blocking,
43
+ scheduler_io_select_supported: scheduler_io_select_supported,
44
+ scheduler_process_wait_supported: scheduler_process_wait_supported,
45
+ scheduler_address_resolve_supported: scheduler_address_resolve_supported
46
+ }.freeze
47
+ end
48
+
49
+ private
50
+
51
+ def normalize_boolean(value, field)
52
+ raise RuntimeContractError, "#{field} must be a Boolean" unless [true, false].include?(value)
53
+
54
+ value
55
+ end
56
+
57
+ def normalize_optional_boolean(value, field)
58
+ return value if value.nil?
59
+ raise RuntimeContractError, "#{field} must be a Boolean or nil" unless [true, false].include?(value)
60
+
61
+ value
62
+ end
63
+ end
64
+
65
+ # Captures immutable scheduler snapshot at the current point in time.
66
+ # This is called at operation start to provide immutable context.
67
+ module SchedulerSnapshotCapture
68
+ module_function
69
+
70
+ def capture
71
+ scheduler = Fiber.scheduler
72
+ scheduler_present = !scheduler.nil?
73
+
74
+ # Normalize Fiber#blocking? to Boolean
75
+ fiber_blocking = begin
76
+ current_fiber = Fiber.current
77
+ if current_fiber.respond_to?(:blocking?)
78
+ !current_fiber.blocking?.nil?
79
+ else
80
+ false
81
+ end
82
+ rescue StandardError
83
+ false
84
+ end
85
+
86
+ # Query scheduler capabilities if present
87
+ io_select_supported = nil
88
+ process_wait_supported = nil
89
+ address_resolve_supported = nil
90
+
91
+ if scheduler_present
92
+ io_select_supported = scheduler.respond_to?(:io_select)
93
+ process_wait_supported = scheduler.respond_to?(:process_wait)
94
+ address_resolve_supported = scheduler.respond_to?(:address_resolve)
95
+ end
96
+
97
+ SchedulerSnapshot.new(
98
+ scheduler_present: scheduler_present,
99
+ fiber_blocking: fiber_blocking,
100
+ scheduler_io_select_supported: io_select_supported,
101
+ scheduler_process_wait_supported: process_wait_supported,
102
+ scheduler_address_resolve_supported: address_resolve_supported
103
+ )
104
+ rescue StandardError
105
+ # Fail open: return a safe default snapshot
106
+ SchedulerSnapshot.new(
107
+ scheduler_present: false,
108
+ fiber_blocking: false
109
+ )
110
+ end
111
+ end
112
+ end
113
+ end
@@ -15,6 +15,7 @@ module FiberAudit
15
15
  class Watchdog
16
16
  SOURCE = :scheduler_watchdog
17
17
  STOP_TIMEOUT_SECONDS = 1
18
+ MAX_OVERLAP_EVENTS = 10
18
19
 
19
20
  Stall = Data.define(:sequence, :progress_sequence, :began_monotonic_ns)
20
21
  Channel = Struct.new(:thread, :heartbeat, :active, :unsupported, :stall, keyword_init: true)
@@ -296,6 +297,45 @@ module FiberAudit
296
297
  measurements: { stall_sequence: @stall_sequence, frame_index: index }
297
298
  )
298
299
  end
300
+ emit_stall_operation_overlap_events(operations, now_ns, snapshot)
301
+ end
302
+
303
+ def emit_stall_operation_overlap_events(operations, now_ns, _snapshot)
304
+ return if operations.empty?
305
+
306
+ truncated = operations.size > MAX_OVERLAP_EVENTS
307
+ bounded_operations = operations.first(MAX_OVERLAP_EVENTS)
308
+
309
+ bounded_operations.each do |entry|
310
+ overlap_measurements = build_overlap_measurements(entry, truncated, operations.size)
311
+ emit_event(
312
+ kind: :scheduler_stall_operation_overlap,
313
+ monotonic_ns: now_ns,
314
+ operation: entry.operation,
315
+ location: entry.location,
316
+ execution_context: entry.execution_context,
317
+ thread_id: entry.thread_id,
318
+ fiber_id: entry.fiber_id,
319
+ measurements: overlap_measurements
320
+ )
321
+ end
322
+ rescue StandardError => e
323
+ account_internal_error unless recorder.disabled?
324
+ raise e unless fail_open?
325
+ end
326
+
327
+ def build_overlap_measurements(entry, truncated, total_count)
328
+ measurements = {
329
+ stall_sequence: @stall_sequence,
330
+ operation_sequence: entry.sequence,
331
+ operation_started_monotonic_ns: entry.started_monotonic_ns,
332
+ overlap_truncated: truncated,
333
+ overlap_total_count: total_count
334
+ }
335
+
336
+ measurements.merge!(entry.scheduler_snapshot.to_measurements) if entry.scheduler_snapshot
337
+
338
+ measurements
299
339
  end
300
340
 
301
341
  def complete_stall(channel, now_ns:, resumed:)
@@ -358,7 +398,8 @@ module FiberAudit
358
398
  )
359
399
  end
360
400
 
361
- def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, measurements: {})
401
+ def emit_event(kind:, monotonic_ns:, duration_ns: nil, location: nil, thread_id: nil, fiber_id: nil, operation: nil,
402
+ execution_context: :unknown, measurements: {})
362
403
  recorder.record_control do
363
404
  Event.new(
364
405
  kind: kind,
@@ -366,8 +407,9 @@ module FiberAudit
366
407
  occurred_at: @clock.wall_time,
367
408
  monotonic_ns: monotonic_ns,
368
409
  duration_ns: duration_ns,
410
+ operation: operation,
369
411
  location: location,
370
- execution_context: :unknown,
412
+ execution_context: execution_context,
371
413
  thread_id: thread_id,
372
414
  fiber_id: fiber_id,
373
415
  measurements: measurements
@@ -22,6 +22,7 @@ require_relative 'runtime/rails_integration'
22
22
  require_relative 'runtime/environment'
23
23
  require_relative 'runtime/active_operations'
24
24
  require_relative 'runtime/heartbeat'
25
+ require_relative 'runtime/scheduler_snapshot'
25
26
  require_relative 'runtime/watchdog'
26
27
  require_relative 'runtime/scheduler_observer'
27
28
  require_relative 'runtime/probes/base'
@@ -20,6 +20,7 @@ module FiberAudit
20
20
  Open3
21
21
  IO
22
22
  Process
23
+ Process::Status
23
24
  Thread
24
25
  Mutex
25
26
  ConditionVariable
@@ -179,6 +179,25 @@ module FiberAudit
179
179
  def severity_index(severity)
180
180
  Severity.index(severity)
181
181
  end
182
+
183
+ # Compute the advisory severity for a finding without applying
184
+ # the context ceiling. Advisory rules (FA1002, FA1003, FA1005,
185
+ # FA1006, FA1007) use this to respect configuration overrides
186
+ # but not escalate based on execution context.
187
+ #
188
+ # Resolution order:
189
+ # 1. configuration.severity_override(rule_id) replaces the default
190
+ # 2. No context ceiling is applied
191
+ #
192
+ # @param default_sev [Symbol, String] rule's default severity
193
+ # @return [Symbol] the resolved severity
194
+ def advisory_severity(default_sev)
195
+ # Normalize String to Symbol defensively
196
+ default_sev = default_sev.to_sym if default_sev.is_a?(String)
197
+
198
+ # Configuration override replaces default entirely, no ceiling
199
+ configuration.severity_override(self.class.id) || default_sev
200
+ end
182
201
  end
183
202
  end
184
203
  end
@@ -9,17 +9,90 @@ require_relative '../../operation_vocabulary'
9
9
  module FiberAudit
10
10
  module Static
11
11
  module Rules
12
+ # FA1001: Subprocess lifecycle operations that may interfere with
13
+ # the fiber scheduler. Detects subprocess creation, replacement,
14
+ # waiting, detachment, and stream lifecycle operations.
15
+ #
16
+ # Operations are classified into semantic categories:
17
+ # - creation: spawning a new process (info)
18
+ # - replacement: replacing the current process via exec (info)
19
+ # - waiting: blocking waits for subprocess completion (medium)
20
+ # - detach: detaching a subprocess without waiting (info)
21
+ # - stream: subprocess pipe/stream lifecycle via IO.popen (medium)
22
+ #
23
+ # Severity: info for creation/replacement/detach, medium for waits/stream.
24
+ # These are scheduler-cooperation requirements, so execution context does
25
+ # not by itself escalate them to critical.
12
26
  class BlockingSubprocess < Base
13
27
  id 'FA1001'
14
- severity :high
28
+ severity :medium
15
29
  default_confidence :high
16
- description 'Blocking subprocess call in the fiber scheduler path'
30
+ description 'Subprocess lifecycle operations may interfere with the fiber scheduler'
17
31
 
18
32
  TARGETS = OperationVocabulary::FA1001_TARGETS
19
33
  BARE_KERNEL_METHODS = OperationVocabulary::FA1001_KERNEL_METHODS
20
34
 
21
- MESSAGE = 'Subprocess operation may block the thread running the fiber scheduler.'
22
- REMEDIATION = 'Move long-running subprocess work outside the request path, or verify scheduler behaviour under load.'
35
+ # Per-operation semantic category
36
+ OPERATION_CATEGORY = {
37
+ # Creation (info) - spawning a new process
38
+ 'Kernel.spawn' => :creation,
39
+ 'Process.spawn' => :creation,
40
+ # Replacement (info) - replacing current process via exec
41
+ 'Kernel.exec' => :replacement,
42
+ 'Process.exec' => :replacement,
43
+ # Waiting (medium) - blocking waits for subprocess completion
44
+ 'Kernel.system' => :waiting,
45
+ 'Process.wait' => :waiting,
46
+ 'Process.wait2' => :waiting,
47
+ 'Process.waitpid' => :waiting,
48
+ 'Process.waitpid2' => :waiting,
49
+ 'Process.waitall' => :waiting,
50
+ 'Process::Status.wait' => :waiting,
51
+ 'Open3.capture2' => :waiting,
52
+ 'Open3.capture2e' => :waiting,
53
+ 'Open3.capture3' => :waiting,
54
+ 'Open3.pipeline' => :waiting,
55
+ # Detach (info) - detaching subprocess without waiting
56
+ 'Process.detach' => :detach,
57
+ # Stream (medium) - subprocess pipe/stream lifecycle
58
+ 'IO.popen' => :stream
59
+ }.freeze
60
+
61
+ # Per-category metadata
62
+ CATEGORY_METADATA = {
63
+ creation: {
64
+ severity: :info,
65
+ title: 'Subprocess creation',
66
+ message: 'Spawning a subprocess may leave background processes that outlive the fiber scheduler session.',
67
+ remediation: 'Track spawned processes or move subprocess creation outside the fiber-scheduled path.'
68
+ },
69
+ replacement: {
70
+ severity: :info,
71
+ title: 'Process replacement',
72
+ message: 'Process replacement via exec replaces the current process image, terminating the fiber scheduler.',
73
+ remediation: 'Avoid exec in fiber-scheduled code; prefer subprocess creation with explicit lifecycle management.'
74
+ },
75
+ waiting: {
76
+ severity: :medium,
77
+ title: 'Subprocess wait',
78
+ message: 'Subprocess waiting requires scheduler process-wait cooperation in a non-blocking Fiber.',
79
+ remediation: 'Verify scheduler process_wait support and runtime progress, ' \
80
+ 'or move waits outside the fiber-scheduled path.'
81
+ },
82
+ detach: {
83
+ severity: :info,
84
+ title: 'Subprocess detach',
85
+ message: 'Detaching a subprocess may leave it unmanaged by the fiber scheduler.',
86
+ remediation: 'Avoid detaching subprocesses in fiber-scheduled code; use explicit lifecycle management.'
87
+ },
88
+ stream: {
89
+ severity: :medium,
90
+ title: 'Subprocess pipe stream',
91
+ message: 'Subprocess pipe I/O requires scheduler cooperation while the stream is open.',
92
+ remediation: 'Verify scheduler-aware I/O and runtime progress on the pipe, ' \
93
+ 'or move pipe operations outside the fiber-scheduled path.'
94
+ }
95
+ }.freeze
23
96
 
24
97
  def analyze(call_sites:)
25
98
  call_sites.filter_map do |site|
@@ -60,11 +133,14 @@ module FiberAudit
60
133
  def build_finding(site, match)
61
134
  operation = "#{match[:constant]}.#{match[:method]}"
62
135
  context = site.execution_context || :unknown
63
- sev = severity_for(self.class.severity, context)
136
+ category = OPERATION_CATEGORY.fetch(operation, :waiting)
137
+ metadata = CATEGORY_METADATA.fetch(category)
138
+ base_severity = metadata[:severity]
139
+ sev = advisory_severity(base_severity)
64
140
 
65
141
  Finding.new(
66
142
  rule_id: self.class.id,
67
- title: 'Blocking subprocess call',
143
+ title: metadata[:title],
68
144
  category: :subprocess,
69
145
  severity: sev,
70
146
  confidence: match[:confidence],
@@ -72,15 +148,19 @@ module FiberAudit
72
148
  symbol: site.enclosing_symbol,
73
149
  operation: operation,
74
150
  execution_context: context,
75
- message: MESSAGE,
151
+ message: metadata[:message],
76
152
  evidence: [
77
153
  Evidence.new(
78
154
  source: :static,
79
- message: "Matched #{operation}",
80
- details: { receiver: match[:constant], method: match[:method] }
155
+ message: "Matched #{operation} (#{category})",
156
+ details: {
157
+ receiver: match[:constant],
158
+ method: match[:method],
159
+ semantic: category
160
+ }
81
161
  )
82
162
  ],
83
- remediation: REMEDIATION
163
+ remediation: metadata[:remediation]
84
164
  )
85
165
  end
86
166
  end