fiber_audit 0.1.0 → 0.2.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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/.fiber-audit.example.yml +19 -0
  3. data/ARCHITECTURE.md +726 -0
  4. data/CHANGELOG.md +30 -0
  5. data/LICENSE +201 -0
  6. data/README.md +66 -8
  7. data/lib/fiber_audit/cli.rb +87 -2
  8. data/lib/fiber_audit/configuration.rb +106 -6
  9. data/lib/fiber_audit/errors.rb +2 -0
  10. data/lib/fiber_audit/operation_vocabulary.rb +42 -0
  11. data/lib/fiber_audit/reporters/text.rb +1 -1
  12. data/lib/fiber_audit/runtime/active_operations.rb +146 -0
  13. data/lib/fiber_audit/runtime/boot.rb +83 -0
  14. data/lib/fiber_audit/runtime/clock.rb +35 -0
  15. data/lib/fiber_audit/runtime/environment.rb +289 -0
  16. data/lib/fiber_audit/runtime/event.rb +86 -0
  17. data/lib/fiber_audit/runtime/execution_context.rb +89 -0
  18. data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
  19. data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
  20. data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
  21. data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
  22. data/lib/fiber_audit/runtime/limits.rb +102 -0
  23. data/lib/fiber_audit/runtime/location.rb +44 -0
  24. data/lib/fiber_audit/runtime/policy.rb +121 -0
  25. data/lib/fiber_audit/runtime/probes/base.rb +333 -0
  26. data/lib/fiber_audit/runtime/probes/http.rb +80 -0
  27. data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
  28. data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
  29. data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
  30. data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
  31. data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
  32. data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
  33. data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
  34. data/lib/fiber_audit/runtime/rails_integration.rb +279 -0
  35. data/lib/fiber_audit/runtime/recorder.rb +333 -0
  36. data/lib/fiber_audit/runtime/redactor.rb +102 -0
  37. data/lib/fiber_audit/runtime/sampler.rb +26 -0
  38. data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
  39. data/lib/fiber_audit/runtime/session.rb +112 -0
  40. data/lib/fiber_audit/runtime/supervisor.rb +114 -0
  41. data/lib/fiber_audit/runtime/validation.rb +68 -0
  42. data/lib/fiber_audit/runtime/watchdog.rb +479 -0
  43. data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
  44. data/lib/fiber_audit/runtime.rb +37 -0
  45. data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
  46. data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
  47. data/lib/fiber_audit/static/rules/io_select.rb +2 -4
  48. data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
  49. data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
  50. data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
  51. data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
  52. data/lib/fiber_audit/version.rb +1 -1
  53. data/lib/fiber_audit.rb +1 -0
  54. metadata +38 -2
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'policy'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ class Sampler
8
+ RANDOM_SOURCE = -> { Random.rand }.freeze
9
+
10
+ attr_reader :policy
11
+
12
+ def initialize(policy:, random: RANDOM_SOURCE)
13
+ raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
14
+ raise RuntimeContractError, 'random source must respond to call' unless random.respond_to?(:call)
15
+
16
+ @policy = policy
17
+ @random = random
18
+ freeze
19
+ end
20
+
21
+ def sample?
22
+ policy.sample?(draw: @random.call)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'watchdog'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ # Explicit-runtime-only hooks for schedulers installed after RUBYOPT boot.
8
+ class SchedulerObserver
9
+ module FiberHook
10
+ # Ruby's scheduler API fixes this writer-like method name.
11
+ # rubocop:disable Naming/AccessorMethodName
12
+ def set_scheduler(scheduler)
13
+ FiberAudit::Runtime::SchedulerObserver.scheduler_replacing(thread: Thread.current) if Fiber.scheduler
14
+ result = super
15
+ if scheduler
16
+ FiberAudit::Runtime::SchedulerObserver.scheduler_installed(
17
+ scheduler: scheduler,
18
+ thread: Thread.current
19
+ )
20
+ end
21
+ result
22
+ end
23
+ # rubocop:enable Naming/AccessorMethodName
24
+ end
25
+
26
+ module SchedulerCloseHook
27
+ def close(...)
28
+ FiberAudit::Runtime::SchedulerObserver.scheduler_closing(thread: Thread.current)
29
+ super
30
+ end
31
+ end
32
+
33
+ class << self
34
+ def activate(watchdog:)
35
+ raise RuntimeContractError, 'watchdog must be a FiberAudit::Runtime::Watchdog' unless watchdog.is_a?(Watchdog)
36
+
37
+ install_fiber_hook!
38
+ observer = new(watchdog: watchdog)
39
+ @current = observer
40
+ observer.attach_existing!
41
+ observer
42
+ end
43
+
44
+ def scheduler_replacing(thread:)
45
+ current_for_process&.scheduler_closing(thread: thread)
46
+ end
47
+
48
+ def scheduler_installed(scheduler:, thread:)
49
+ observer = current_for_process
50
+ return unless observer
51
+
52
+ observer.scheduler_installed(scheduler: scheduler, thread: thread)
53
+ end
54
+
55
+ def scheduler_closing(thread:)
56
+ current_for_process&.scheduler_closing(thread: thread)
57
+ end
58
+
59
+ def deactivate(observer)
60
+ @current = nil if @current.equal?(observer)
61
+ observer.deactivate
62
+ end
63
+
64
+ private
65
+
66
+ def current_for_process
67
+ observer = @current
68
+ return observer if observer&.active_for_current_process?
69
+
70
+ nil
71
+ end
72
+
73
+ def install_fiber_hook!
74
+ singleton = Fiber.singleton_class
75
+ singleton.prepend(FiberHook) unless singleton.ancestors.include?(FiberHook)
76
+ end
77
+ end
78
+
79
+ attr_reader :watchdog, :owner_pid
80
+
81
+ def initialize(watchdog:)
82
+ @watchdog = watchdog
83
+ @owner_pid = Process.pid
84
+ @active = true
85
+ end
86
+
87
+ def active_for_current_process?
88
+ @active && owner_pid == Process.pid
89
+ end
90
+
91
+ def attach_existing!
92
+ scheduler = Fiber.scheduler
93
+ scheduler_installed(scheduler: scheduler, thread: Thread.current) if scheduler
94
+ self
95
+ end
96
+
97
+ def scheduler_installed(scheduler:, thread:)
98
+ return self unless active_for_current_process? && watchdog.enabled?
99
+
100
+ install_close_hook!(scheduler)
101
+ watchdog.scheduler_installed(thread: thread)
102
+ self
103
+ rescue StandardError
104
+ watchdog.scheduler_unsupported(thread: thread)
105
+ raise unless watchdog.fail_open?
106
+
107
+ self
108
+ end
109
+
110
+ def scheduler_closing(thread:)
111
+ watchdog.scheduler_closing(thread: thread) if active_for_current_process?
112
+ self
113
+ end
114
+
115
+ def deactivate
116
+ @active = false
117
+ self
118
+ end
119
+
120
+ private
121
+
122
+ def install_close_hook!(scheduler)
123
+ singleton = scheduler.singleton_class
124
+ singleton.prepend(SchedulerCloseHook) unless singleton.ancestors.include?(SchedulerCloseHook)
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../version'
4
+ require_relative 'policy'
5
+ require_relative 'validation'
6
+
7
+ module FiberAudit
8
+ module Runtime
9
+ Session = Data.define(
10
+ :id,
11
+ :started_at,
12
+ :started_monotonic_ns,
13
+ :policy,
14
+ :tool_version,
15
+ :ruby_version
16
+ ) do
17
+ def initialize(
18
+ id:,
19
+ started_at:,
20
+ started_monotonic_ns:,
21
+ policy: Policy.new,
22
+ tool_version: FiberAudit::VERSION,
23
+ ruby_version: RUBY_VERSION
24
+ )
25
+ super(
26
+ id: normalize_id(id),
27
+ started_at: Validation.utc_time(started_at, 'started_at'),
28
+ started_monotonic_ns: Validation.integer(started_monotonic_ns, 'started_monotonic_ns'),
29
+ policy: normalize_policy(policy),
30
+ tool_version: Validation.string(tool_version, 'tool_version', max_bytes: 64),
31
+ ruby_version: Validation.string(ruby_version, 'ruby_version', max_bytes: 64)
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def normalize_id(value)
38
+ unless value.is_a?(String) && value.match?(Validation::UUID)
39
+ raise RuntimeContractError, 'id must be a canonical lowercase UUID'
40
+ end
41
+
42
+ value.dup.freeze
43
+ end
44
+
45
+ def normalize_policy(value)
46
+ return value if value.is_a?(Policy)
47
+
48
+ raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy'
49
+ end
50
+ end
51
+
52
+ SessionSummary = Data.define(
53
+ :ended_at,
54
+ :ended_monotonic_ns,
55
+ :status,
56
+ :events_observed,
57
+ :events_emitted,
58
+ :sampled_out,
59
+ :rate_limited,
60
+ :session_event_limited,
61
+ :session_byte_limited,
62
+ :oversize,
63
+ :internal_errors
64
+ ) do
65
+ def initialize(ended_at:, ended_monotonic_ns:, status:, **counters)
66
+ unknown = counters.keys - SessionSummary::COUNTERS
67
+ missing = SessionSummary::COUNTERS - counters.keys
68
+ raise RuntimeContractError, "unknown session summary field: #{unknown.first}" unless unknown.empty?
69
+ raise RuntimeContractError, "missing session summary field: #{missing.first}" unless missing.empty?
70
+
71
+ normalized_status = status.is_a?(String) || status.is_a?(Symbol) ? status.to_sym : nil
72
+ unless SessionSummary::STATUSES.include?(normalized_status)
73
+ raise RuntimeContractError, "status must be one of: #{SessionSummary::STATUSES.join(', ')}"
74
+ end
75
+
76
+ normalized_counters = counters.to_h do |name, value|
77
+ [name, Validation.integer(value, name.to_s)]
78
+ end
79
+ accounted = %i[
80
+ events_emitted
81
+ sampled_out
82
+ rate_limited
83
+ session_event_limited
84
+ session_byte_limited
85
+ oversize
86
+ ].sum { |name| normalized_counters.fetch(name) }
87
+ if accounted > normalized_counters.fetch(:events_observed)
88
+ raise RuntimeContractError, 'event accounting exceeds events_observed'
89
+ end
90
+
91
+ super(
92
+ ended_at: Validation.utc_time(ended_at, 'ended_at'),
93
+ ended_monotonic_ns: Validation.integer(ended_monotonic_ns, 'ended_monotonic_ns'),
94
+ status: normalized_status,
95
+ **normalized_counters
96
+ )
97
+ end
98
+ end
99
+
100
+ SessionSummary.const_set(:STATUSES, %i[completed degraded aborted].freeze)
101
+ SessionSummary.const_set(:COUNTERS, %i[
102
+ events_observed
103
+ events_emitted
104
+ sampled_out
105
+ rate_limited
106
+ session_event_limited
107
+ session_byte_limited
108
+ oversize
109
+ internal_errors
110
+ ].freeze)
111
+ end
112
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ class Supervisor
8
+ SIGNALS = %w[INT TERM HUP QUIT].select { |name| Signal.list.key?(name) }.freeze
9
+
10
+ class SystemAdapter
11
+ def spawn(environment, command, cwd)
12
+ executable = command.fetch(0)
13
+ Process.spawn(
14
+ environment,
15
+ [executable, executable],
16
+ *command.drop(1),
17
+ chdir: cwd,
18
+ pgroup: true
19
+ )
20
+ end
21
+
22
+ def wait2(pid)
23
+ Process.wait2(pid)
24
+ end
25
+
26
+ def trap(signal, handler = nil, &)
27
+ Signal.trap(signal, handler, &)
28
+ end
29
+
30
+ def kill(signal, pid)
31
+ Process.kill(signal, pid)
32
+ end
33
+ end
34
+
35
+ def initialize(command:, environment:, cwd:, adapter: SystemAdapter.new)
36
+ validate_command!(command)
37
+ raise RuntimeContractError, 'child environment must be a Hash' unless environment.is_a?(Hash)
38
+ raise RuntimeContractError, 'runtime cwd must be an existing directory' unless File.directory?(cwd)
39
+
40
+ @command = command.map { |argument| argument.dup.freeze }.freeze
41
+ @environment = environment.transform_values { |value| value&.dup&.freeze }.freeze
42
+ @cwd = File.expand_path(cwd).freeze
43
+ @adapter = adapter
44
+ @child_pid = nil
45
+ end
46
+
47
+ def run
48
+ handlers = {}
49
+ begin
50
+ # Install traps only after spawn so POSIX-spawned children cannot inherit
51
+ # FiberAudit's forwarding handlers and accidentally ignore termination.
52
+ @child_pid = @adapter.spawn(@environment, @command, @cwd)
53
+ install_handlers(handlers)
54
+ _pid, status = wait_for_child
55
+ status_code(status)
56
+ ensure
57
+ restore_handlers(handlers)
58
+ @child_pid = nil
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def validate_command!(command)
65
+ unless command.is_a?(Array) && !command.empty? && command.all?(String)
66
+ raise RuntimeContractError, 'runtime command must be a nonempty Array of Strings'
67
+ end
68
+
69
+ command.each_with_index do |argument, index|
70
+ next if argument.valid_encoding? && !argument.include?("\0") && (index.positive? || !argument.empty?)
71
+
72
+ raise RuntimeContractError, 'runtime command contains an invalid argument'
73
+ end
74
+ end
75
+
76
+ def install_handlers(handlers)
77
+ SIGNALS.each do |signal|
78
+ handlers[signal] = @adapter.trap(signal) { receive_signal(signal) }
79
+ end
80
+ end
81
+
82
+ def restore_handlers(handlers)
83
+ handlers.each { |signal, previous| @adapter.trap(signal, previous) }
84
+ end
85
+
86
+ def receive_signal(signal)
87
+ forward_signal(signal) if @child_pid
88
+ end
89
+
90
+ def forward_signal(signal)
91
+ @adapter.kill(signal, -@child_pid)
92
+ rescue Errno::ESRCH, Errno::EINVAL, NotImplementedError
93
+ begin
94
+ @adapter.kill(signal, @child_pid)
95
+ rescue Errno::ESRCH, Errno::EINVAL, NotImplementedError
96
+ nil
97
+ end
98
+ end
99
+
100
+ def wait_for_child
101
+ @adapter.wait2(@child_pid)
102
+ rescue Errno::EINTR
103
+ retry
104
+ end
105
+
106
+ def status_code(status)
107
+ return status.exitstatus if status.exited?
108
+ return 128 + status.termsig if status.signaled?
109
+
110
+ raise RuntimeSafetyError, 'wrapped command ended without an exit status'
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../errors'
4
+
5
+ module FiberAudit
6
+ module Runtime
7
+ module Validation
8
+ IDENTIFIER = /\A[a-z][a-z0-9_]*\z/
9
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/
10
+ CONTROL = /[[:cntrl:]]/
11
+ OPERATION = /\A(?:::)?[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*(?:[.#](?:[a-zA-Z_][a-zA-Z0-9_]*[!?=]?|\[\]=?))\z/
12
+ SPECIAL_OPERATIONS = %w[Thread.current.[] Thread.current.[]=].freeze
13
+
14
+ module_function
15
+
16
+ def identifier(value, field, max_bytes: 64)
17
+ normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_s : nil
18
+ unless normalized&.match?(IDENTIFIER) && normalized.bytesize <= max_bytes
19
+ raise RuntimeContractError, "#{field} must be a snake-case identifier"
20
+ end
21
+
22
+ normalized.to_sym
23
+ end
24
+
25
+ def string(value, field, max_bytes:, allow_nil: false)
26
+ return if value.nil? && allow_nil
27
+ unless value.is_a?(String) && value.valid_encoding? && !value.empty? &&
28
+ value.bytesize <= max_bytes && !value.match?(CONTROL)
29
+ raise RuntimeContractError, "#{field} must be a non-empty UTF-8 String without control characters"
30
+ end
31
+
32
+ value.dup.freeze
33
+ end
34
+
35
+ def operation(value, allow_nil: false)
36
+ return if value.nil? && allow_nil
37
+
38
+ normalized = string(value, 'operation', max_bytes: 256)
39
+ unless normalized == '[redacted]' || SPECIAL_OPERATIONS.include?(normalized) || normalized.match?(OPERATION)
40
+ raise RuntimeContractError, 'operation must be canonical or redacted'
41
+ end
42
+
43
+ normalized
44
+ end
45
+
46
+ def integer(value, field, minimum: 0, allow_nil: false)
47
+ return if value.nil? && allow_nil
48
+ unless value.is_a?(Integer) && value >= minimum
49
+ raise RuntimeContractError, "#{field} must be an Integer >= #{minimum}"
50
+ end
51
+
52
+ value
53
+ end
54
+
55
+ def finite_number(value, field)
56
+ raise RuntimeContractError, "#{field} must be a finite number" unless value.is_a?(Numeric) && value.finite?
57
+
58
+ value
59
+ end
60
+
61
+ def utc_time(value, field)
62
+ raise RuntimeContractError, "#{field} must be a Time" unless value.is_a?(Time)
63
+
64
+ value.getutc.freeze
65
+ end
66
+ end
67
+ end
68
+ end