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.
- checksums.yaml +4 -4
- data/.fiber-audit.example.yml +19 -0
- data/ARCHITECTURE.md +726 -0
- data/CHANGELOG.md +30 -0
- data/LICENSE +201 -0
- data/README.md +66 -8
- data/lib/fiber_audit/cli.rb +87 -2
- data/lib/fiber_audit/configuration.rb +106 -6
- data/lib/fiber_audit/errors.rb +2 -0
- data/lib/fiber_audit/operation_vocabulary.rb +42 -0
- data/lib/fiber_audit/reporters/text.rb +1 -1
- data/lib/fiber_audit/runtime/active_operations.rb +146 -0
- data/lib/fiber_audit/runtime/boot.rb +83 -0
- data/lib/fiber_audit/runtime/clock.rb +35 -0
- data/lib/fiber_audit/runtime/environment.rb +289 -0
- data/lib/fiber_audit/runtime/event.rb +86 -0
- data/lib/fiber_audit/runtime/execution_context.rb +89 -0
- data/lib/fiber_audit/runtime/heartbeat.rb +113 -0
- data/lib/fiber_audit/runtime/jsonl/schema.rb +312 -0
- data/lib/fiber_audit/runtime/jsonl/writer.rb +122 -0
- data/lib/fiber_audit/runtime/lifecycle.rb +343 -0
- data/lib/fiber_audit/runtime/limits.rb +102 -0
- data/lib/fiber_audit/runtime/location.rb +44 -0
- data/lib/fiber_audit/runtime/policy.rb +121 -0
- data/lib/fiber_audit/runtime/probes/base.rb +333 -0
- data/lib/fiber_audit/runtime/probes/http.rb +80 -0
- data/lib/fiber_audit/runtime/probes/io_select.rb +50 -0
- data/lib/fiber_audit/runtime/probes/registry.rb +156 -0
- data/lib/fiber_audit/runtime/probes/socket.rb +76 -0
- data/lib/fiber_audit/runtime/probes/subprocess.rb +83 -0
- data/lib/fiber_audit/runtime/probes/synchronization.rb +58 -0
- data/lib/fiber_audit/runtime/probes/thread_state.rb +39 -0
- data/lib/fiber_audit/runtime/probes/thread_wait.rb +25 -0
- data/lib/fiber_audit/runtime/rails_integration.rb +279 -0
- data/lib/fiber_audit/runtime/recorder.rb +333 -0
- data/lib/fiber_audit/runtime/redactor.rb +102 -0
- data/lib/fiber_audit/runtime/sampler.rb +26 -0
- data/lib/fiber_audit/runtime/scheduler_observer.rb +128 -0
- data/lib/fiber_audit/runtime/session.rb +112 -0
- data/lib/fiber_audit/runtime/supervisor.rb +114 -0
- data/lib/fiber_audit/runtime/validation.rb +68 -0
- data/lib/fiber_audit/runtime/watchdog.rb +479 -0
- data/lib/fiber_audit/runtime/watchdog_policy.rb +64 -0
- data/lib/fiber_audit/runtime.rb +37 -0
- data/lib/fiber_audit/static/rules/blocking_subprocess.rb +3 -8
- data/lib/fiber_audit/static/rules/direct_socket.rb +2 -3
- data/lib/fiber_audit/static/rules/io_select.rb +2 -4
- data/lib/fiber_audit/static/rules/net_http_in_request.rb +3 -5
- data/lib/fiber_audit/static/rules/synchronization.rb +2 -6
- data/lib/fiber_audit/static/rules/thread_current_state.rb +3 -2
- data/lib/fiber_audit/static/rules/thread_join.rb +3 -2
- data/lib/fiber_audit/version.rb +1 -1
- data/lib/fiber_audit.rb +1 -0
- metadata +38 -2
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'execution_context'
|
|
4
|
+
|
|
5
|
+
module FiberAudit
|
|
6
|
+
module Runtime
|
|
7
|
+
# Process-local, idempotent Rails integration.
|
|
8
|
+
# Installs prepend hooks for Rails boundaries when detected.
|
|
9
|
+
# Becomes inert after deactivation or fork.
|
|
10
|
+
# Supports late-load rescanning for Rails components loaded after boot.
|
|
11
|
+
class RailsIntegration
|
|
12
|
+
attr_reader :owner_pid, :context_store, :recorder
|
|
13
|
+
|
|
14
|
+
def initialize(context_store:, recorder: nil)
|
|
15
|
+
@context_store = context_store
|
|
16
|
+
@recorder = recorder
|
|
17
|
+
@owner_pid = Process.pid
|
|
18
|
+
@active = true
|
|
19
|
+
@middleware_stack = nil
|
|
20
|
+
@require_hook_installed = false
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def active_for_current_process?
|
|
25
|
+
@active && @owner_pid == Process.pid
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def deactivate!
|
|
29
|
+
@active = false
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def install!
|
|
34
|
+
return unless active_for_current_process?
|
|
35
|
+
|
|
36
|
+
@mutex.synchronize do
|
|
37
|
+
install_require_hook
|
|
38
|
+
install_controller_hook
|
|
39
|
+
install_job_hook
|
|
40
|
+
install_cable_hook
|
|
41
|
+
try_install_middleware_hook
|
|
42
|
+
end
|
|
43
|
+
self
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
handle_installation_failure(e)
|
|
46
|
+
self
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rescan!
|
|
50
|
+
return unless active_for_current_process?
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
install_controller_hook
|
|
54
|
+
install_job_hook
|
|
55
|
+
install_cable_hook
|
|
56
|
+
try_install_middleware_hook
|
|
57
|
+
end
|
|
58
|
+
self
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
handle_installation_failure(e)
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
def activate(context_store: ExecutionContext, recorder: nil)
|
|
66
|
+
integration = new(context_store: context_store, recorder: recorder)
|
|
67
|
+
integration.install!
|
|
68
|
+
@current = integration
|
|
69
|
+
@current
|
|
70
|
+
rescue StandardError
|
|
71
|
+
@current = nil
|
|
72
|
+
raise
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
attr_reader :current
|
|
76
|
+
|
|
77
|
+
def deactivate(integration = nil)
|
|
78
|
+
target = integration || @current
|
|
79
|
+
return unless target
|
|
80
|
+
|
|
81
|
+
target.deactivate!
|
|
82
|
+
@current = nil if @current.equal?(target)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def rescan!
|
|
86
|
+
@current&.rescan! if @current&.active_for_current_process?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def rescan_after_require!
|
|
90
|
+
key = :fiber_audit_rails_require_rescan
|
|
91
|
+
thread = Thread.current
|
|
92
|
+
acquired = false
|
|
93
|
+
return if thread.thread_variable_get(key)
|
|
94
|
+
|
|
95
|
+
thread.thread_variable_set(key, true)
|
|
96
|
+
acquired = true
|
|
97
|
+
rescan!
|
|
98
|
+
ensure
|
|
99
|
+
thread&.thread_variable_set(key, false) if acquired
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def handle_installation_failure(error)
|
|
106
|
+
unless error.instance_variable_defined?(:@fiber_audit_runtime_accounted)
|
|
107
|
+
recorder&.internal_error! unless recorder&.disabled?
|
|
108
|
+
error.instance_variable_set(:@fiber_audit_runtime_accounted, true)
|
|
109
|
+
end
|
|
110
|
+
raise error unless fail_open?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def fail_open?
|
|
114
|
+
return true unless recorder
|
|
115
|
+
|
|
116
|
+
recorder.session.policy.fail_open?
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def account_internal_error
|
|
120
|
+
return unless recorder
|
|
121
|
+
|
|
122
|
+
recorder.internal_error! unless recorder.disabled?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def install_require_hook
|
|
126
|
+
return if @require_hook_installed
|
|
127
|
+
|
|
128
|
+
# Install narrow guarded require hook for late Rails loading
|
|
129
|
+
# Independent of probe registry
|
|
130
|
+
::Kernel.prepend(RequireHook) unless ::Kernel.ancestors.include?(RequireHook)
|
|
131
|
+
@require_hook_installed = true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def install_controller_hook
|
|
135
|
+
return unless defined?(::ActionController::Metal)
|
|
136
|
+
|
|
137
|
+
::ActionController::Metal.prepend(ControllerHook) unless ::ActionController::Metal.ancestors.include?(ControllerHook)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def install_job_hook
|
|
141
|
+
return unless defined?(::ActiveJob::Base)
|
|
142
|
+
|
|
143
|
+
::ActiveJob::Base.prepend(JobHook) unless ::ActiveJob::Base.ancestors.include?(JobHook)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def install_cable_hook
|
|
147
|
+
return unless defined?(::ActionCable::Channel::Base)
|
|
148
|
+
|
|
149
|
+
::ActionCable::Channel::Base.prepend(CableHook) unless ::ActionCable::Channel::Base.ancestors.include?(CableHook)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def try_install_middleware_hook
|
|
153
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
154
|
+
|
|
155
|
+
stack = ::Rails.application.config.middleware
|
|
156
|
+
return if @middleware_stack.equal?(stack)
|
|
157
|
+
|
|
158
|
+
# Try to insert middleware into Rails stack. A replacement stack (for
|
|
159
|
+
# example after a Rails reload) is eligible for installation again.
|
|
160
|
+
begin
|
|
161
|
+
stack.use(Middleware)
|
|
162
|
+
@middleware_stack = stack
|
|
163
|
+
rescue StandardError => e
|
|
164
|
+
# Stack may be finalized - that's okay, middleware is optional
|
|
165
|
+
handle_installation_failure(e)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Require hook for late Rails loading - independent of probe registry
|
|
170
|
+
# Narrow, guarded, behavior-preserving
|
|
171
|
+
module RequireHook
|
|
172
|
+
def require(path)
|
|
173
|
+
result = super
|
|
174
|
+
# Only rescan after a successful require that loaded a feature.
|
|
175
|
+
RailsIntegration.rescan_after_require! if result
|
|
176
|
+
result
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private :require
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Prepend hooks - consult active integration before setting context
|
|
183
|
+
# Resilient to context store failures in fail-open mode
|
|
184
|
+
# Critical: must not catch application exceptions or invoke app twice
|
|
185
|
+
module ControllerHook
|
|
186
|
+
def process_action(...)
|
|
187
|
+
integration = RailsIntegration.current
|
|
188
|
+
return super unless integration&.active_for_current_process?
|
|
189
|
+
|
|
190
|
+
executed = false
|
|
191
|
+
begin
|
|
192
|
+
integration.context_store.with(:request) do
|
|
193
|
+
executed = true
|
|
194
|
+
super
|
|
195
|
+
end
|
|
196
|
+
rescue StandardError
|
|
197
|
+
raise if executed
|
|
198
|
+
# Context setup failed before application code ran
|
|
199
|
+
raise unless integration.send(:fail_open?)
|
|
200
|
+
|
|
201
|
+
integration.send(:account_internal_error)
|
|
202
|
+
super
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
module JobHook
|
|
208
|
+
def perform_now(...)
|
|
209
|
+
integration = RailsIntegration.current
|
|
210
|
+
return super unless integration&.active_for_current_process?
|
|
211
|
+
|
|
212
|
+
executed = false
|
|
213
|
+
begin
|
|
214
|
+
integration.context_store.with(:job) do
|
|
215
|
+
executed = true
|
|
216
|
+
super
|
|
217
|
+
end
|
|
218
|
+
rescue StandardError
|
|
219
|
+
raise if executed
|
|
220
|
+
# Context setup failed before application code ran
|
|
221
|
+
raise unless integration.send(:fail_open?)
|
|
222
|
+
|
|
223
|
+
integration.send(:account_internal_error)
|
|
224
|
+
super
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
module CableHook
|
|
230
|
+
def dispatch_action(...)
|
|
231
|
+
integration = RailsIntegration.current
|
|
232
|
+
return super unless integration&.active_for_current_process?
|
|
233
|
+
|
|
234
|
+
executed = false
|
|
235
|
+
begin
|
|
236
|
+
integration.context_store.with(:websocket) do
|
|
237
|
+
executed = true
|
|
238
|
+
super
|
|
239
|
+
end
|
|
240
|
+
rescue StandardError
|
|
241
|
+
raise if executed
|
|
242
|
+
# Context setup failed before application code ran
|
|
243
|
+
raise unless integration.send(:fail_open?)
|
|
244
|
+
|
|
245
|
+
integration.send(:account_internal_error)
|
|
246
|
+
super
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Rack middleware for :middleware context
|
|
252
|
+
class Middleware
|
|
253
|
+
def initialize(app)
|
|
254
|
+
@app = app
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def call(env)
|
|
258
|
+
integration = RailsIntegration.current
|
|
259
|
+
return @app.call(env) unless integration&.active_for_current_process?
|
|
260
|
+
|
|
261
|
+
executed = false
|
|
262
|
+
begin
|
|
263
|
+
integration.context_store.with(:middleware) do
|
|
264
|
+
executed = true
|
|
265
|
+
@app.call(env)
|
|
266
|
+
end
|
|
267
|
+
rescue StandardError
|
|
268
|
+
raise if executed
|
|
269
|
+
# Context setup failed before application code ran
|
|
270
|
+
raise unless integration.send(:fail_open?)
|
|
271
|
+
|
|
272
|
+
integration.send(:account_internal_error)
|
|
273
|
+
@app.call(env)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'clock'
|
|
4
|
+
require_relative 'event'
|
|
5
|
+
require_relative 'jsonl/writer'
|
|
6
|
+
require_relative 'limits'
|
|
7
|
+
require_relative 'sampler'
|
|
8
|
+
require_relative 'session'
|
|
9
|
+
|
|
10
|
+
module FiberAudit
|
|
11
|
+
module Runtime
|
|
12
|
+
# Thread-safe coordinator for one bounded append-only runtime session.
|
|
13
|
+
# rubocop:disable Metrics/ClassLength
|
|
14
|
+
class Recorder
|
|
15
|
+
RESULTS = %i[
|
|
16
|
+
emitted sampled_out rate_limited session_event_limited session_byte_limited
|
|
17
|
+
oversize internal_error inactive
|
|
18
|
+
].freeze
|
|
19
|
+
MAX_COUNTER = Limits::MAX_COUNTER
|
|
20
|
+
OUTCOME_COUNTERS = 6
|
|
21
|
+
|
|
22
|
+
attr_reader :session, :writer
|
|
23
|
+
|
|
24
|
+
def self.start(...)
|
|
25
|
+
new(...)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(session:, writer:, clock: Clock.new, random: Sampler::RANDOM_SOURCE)
|
|
29
|
+
validate_dependencies!(session, writer, clock, random)
|
|
30
|
+
@session = session
|
|
31
|
+
@writer = writer
|
|
32
|
+
@clock = clock
|
|
33
|
+
@mutex = Mutex.new
|
|
34
|
+
@state = :starting
|
|
35
|
+
@sequence = 1
|
|
36
|
+
@in_flight = 0
|
|
37
|
+
@start_written = false
|
|
38
|
+
@summary = nil
|
|
39
|
+
@limits = Limits.new(policy: session.policy, started_monotonic_ns: session.started_monotonic_ns)
|
|
40
|
+
@sampler = Sampler.new(policy: session.policy, random: random)
|
|
41
|
+
@end_reserve_bytes = end_reserve_bytes
|
|
42
|
+
start_session!
|
|
43
|
+
rescue StandardError => e
|
|
44
|
+
startup_failure!(e)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def record(&factory)
|
|
48
|
+
record_observation(sample: true, factory: factory)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Control evidence is never sampled, but still consumes every configured
|
|
52
|
+
# rate, count, record-size, and session-size budget.
|
|
53
|
+
def record_control(&factory)
|
|
54
|
+
record_observation(sample: false, factory: factory)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Accounts an instrumentation failure without retaining exception data.
|
|
58
|
+
def internal_error!
|
|
59
|
+
@mutex.synchronize do
|
|
60
|
+
return false if @state == :closed
|
|
61
|
+
|
|
62
|
+
@limits.internal_error!
|
|
63
|
+
true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def close(status: :completed)
|
|
68
|
+
requested_status = normalize_status(status)
|
|
69
|
+
@mutex.synchronize do
|
|
70
|
+
return @summary if @summary
|
|
71
|
+
|
|
72
|
+
@state = :closing
|
|
73
|
+
@limits.internal_error!(count: @in_flight) if @in_flight.positive?
|
|
74
|
+
ended_at, ended_monotonic_ns, clock_error = closing_times
|
|
75
|
+
errors = [clock_error].compact
|
|
76
|
+
summary = build_summary(requested_status, ended_at, ended_monotonic_ns)
|
|
77
|
+
errors.concat(write_end_record(summary))
|
|
78
|
+
errors.concat(close_writer)
|
|
79
|
+
summary = build_summary(requested_status, ended_at, ended_monotonic_ns) unless errors.empty?
|
|
80
|
+
@summary = summary
|
|
81
|
+
@state = :closed
|
|
82
|
+
raise errors.first if errors.any? && !session.policy.fail_open?
|
|
83
|
+
|
|
84
|
+
@summary
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def active?
|
|
89
|
+
state?(:active)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def disabled?
|
|
93
|
+
state?(:disabled)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def closed?
|
|
97
|
+
state?(:closed)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def summary
|
|
101
|
+
@mutex.synchronize { @summary }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def validate_dependencies!(candidate_session, candidate_writer, candidate_clock, candidate_random)
|
|
107
|
+
raise RuntimeContractError, 'session must be a FiberAudit::Runtime::Session' unless candidate_session.is_a?(Session)
|
|
108
|
+
unless candidate_writer.is_a?(JSONL::Writer)
|
|
109
|
+
raise RuntimeContractError, 'writer must be a FiberAudit::Runtime::JSONL::Writer'
|
|
110
|
+
end
|
|
111
|
+
raise RuntimeContractError, 'clock must be a FiberAudit::Runtime::Clock' unless candidate_clock.is_a?(Clock)
|
|
112
|
+
raise RuntimeContractError, 'random source must respond to call' unless candidate_random.respond_to?(:call)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def start_session!
|
|
116
|
+
raise RuntimeSafetyError, 'runtime JSONL writer is not empty' unless writer.bytes_written.zero?
|
|
117
|
+
if session.started_monotonic_ns > MAX_COUNTER
|
|
118
|
+
raise RuntimeSafetyError, 'session monotonic start exceeds signed 64-bit range'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
line = writer.prepare(JSONL::Schema.start_record(session))
|
|
122
|
+
required = line.bytesize + @end_reserve_bytes
|
|
123
|
+
unless session.policy.session_bytes_allowed?(written_bytes: 0, next_record_bytes: required)
|
|
124
|
+
raise RuntimeSafetyError, 'runtime session limit cannot contain start and end records'
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
writer.write_line(line)
|
|
128
|
+
@start_written = true
|
|
129
|
+
@state = :active
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def startup_failure!(error)
|
|
133
|
+
raise error unless defined?(@session) && session.policy.fail_open?
|
|
134
|
+
|
|
135
|
+
@limits&.internal_error!
|
|
136
|
+
@state = :disabled
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def record_observation(sample:, factory:)
|
|
140
|
+
raise ArgumentError, 'record requires an event factory block' unless factory
|
|
141
|
+
|
|
142
|
+
decision = begin_observation(sample: sample)
|
|
143
|
+
return decision unless decision == :selected
|
|
144
|
+
|
|
145
|
+
completed = false
|
|
146
|
+
begin
|
|
147
|
+
event = factory.call
|
|
148
|
+
completed = true
|
|
149
|
+
ensure
|
|
150
|
+
release_failed_factory unless completed
|
|
151
|
+
end
|
|
152
|
+
finish_observation(event)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def begin_observation(sample:)
|
|
156
|
+
@mutex.synchronize do
|
|
157
|
+
return :inactive unless @state == :active
|
|
158
|
+
|
|
159
|
+
@limits.observe!
|
|
160
|
+
if sample && !@sampler.sample?
|
|
161
|
+
@limits.drop!(:sampled_out)
|
|
162
|
+
return :sampled_out
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
@in_flight += 1
|
|
166
|
+
:selected
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
handle_internal_error!(e)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def finish_observation(event)
|
|
173
|
+
@mutex.synchronize do
|
|
174
|
+
@in_flight -= 1
|
|
175
|
+
return finish_inactive_observation unless @state == :active
|
|
176
|
+
|
|
177
|
+
emit_or_drop(event)
|
|
178
|
+
rescue StandardError => e
|
|
179
|
+
handle_internal_error!(e)
|
|
180
|
+
ensure
|
|
181
|
+
if writer.failed? && @state == :active
|
|
182
|
+
@limits.internal_error!
|
|
183
|
+
@state = :disabled
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def finish_inactive_observation
|
|
189
|
+
@limits.internal_error! if @state == :disabled
|
|
190
|
+
:inactive
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def emit_or_drop(event)
|
|
194
|
+
raise RuntimeContractError, 'event factory must return a FiberAudit::Runtime::Event' unless event.is_a?(Event)
|
|
195
|
+
|
|
196
|
+
now_ns = @clock.monotonic_ns
|
|
197
|
+
reason = @limits.preflight_event(now_ns: now_ns)
|
|
198
|
+
return account_preflight_drop(reason) if reason
|
|
199
|
+
|
|
200
|
+
line = prepare_event(event)
|
|
201
|
+
return :oversize unless line
|
|
202
|
+
|
|
203
|
+
unless event_bytes_allowed?(line)
|
|
204
|
+
@limits.drop!(:session_byte_limited)
|
|
205
|
+
return :session_byte_limited
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
writer.write_line(line)
|
|
209
|
+
@limits.emitted!(now_ns: now_ns)
|
|
210
|
+
@sequence += 1
|
|
211
|
+
:emitted
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def account_preflight_drop(reason)
|
|
215
|
+
@limits.drop!(reason)
|
|
216
|
+
reason
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def prepare_event(event)
|
|
220
|
+
record = JSONL::Schema.event_record(session_id: session.id, sequence: @sequence, event: event)
|
|
221
|
+
writer.prepare(record)
|
|
222
|
+
rescue RuntimeSafetyError
|
|
223
|
+
@limits.drop!(:oversize)
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def event_bytes_allowed?(line)
|
|
228
|
+
required = line.bytesize + @end_reserve_bytes
|
|
229
|
+
session.policy.session_bytes_allowed?(
|
|
230
|
+
written_bytes: writer.bytes_written,
|
|
231
|
+
next_record_bytes: required
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def release_failed_factory
|
|
236
|
+
@mutex.synchronize do
|
|
237
|
+
@in_flight -= 1
|
|
238
|
+
@limits.internal_error! if @state == :disabled
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def handle_internal_error!(error)
|
|
243
|
+
@limits.internal_error!
|
|
244
|
+
@state = :disabled
|
|
245
|
+
raise error unless session.policy.fail_open?
|
|
246
|
+
|
|
247
|
+
:internal_error
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def closing_times
|
|
251
|
+
[@clock.wall_time, @clock.monotonic_ns, nil]
|
|
252
|
+
rescue StandardError => e
|
|
253
|
+
@limits.internal_error!
|
|
254
|
+
[session.started_at, session.started_monotonic_ns, e]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def write_end_record(summary)
|
|
258
|
+
return [] unless @start_written && writer.active?
|
|
259
|
+
|
|
260
|
+
record = JSONL::Schema.end_record(session_id: session.id, sequence: @sequence, summary: summary)
|
|
261
|
+
line = writer.prepare(record)
|
|
262
|
+
if line.bytesize > @end_reserve_bytes
|
|
263
|
+
raise RuntimeSafetyError, 'runtime session end record exceeded its reserved bytes'
|
|
264
|
+
end
|
|
265
|
+
unless session.policy.session_bytes_allowed?(written_bytes: writer.bytes_written, next_record_bytes: line.bytesize)
|
|
266
|
+
raise RuntimeSafetyError, 'runtime session end record exceeded the session byte limit'
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
writer.write_line(line)
|
|
270
|
+
[]
|
|
271
|
+
rescue StandardError => e
|
|
272
|
+
@limits.internal_error!
|
|
273
|
+
[e]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def close_writer
|
|
277
|
+
writer.close
|
|
278
|
+
[]
|
|
279
|
+
rescue StandardError => e
|
|
280
|
+
@limits.internal_error!
|
|
281
|
+
[e]
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def build_summary(requested_status, ended_at, ended_monotonic_ns)
|
|
285
|
+
status = summary_status(requested_status)
|
|
286
|
+
SessionSummary.new(
|
|
287
|
+
ended_at: ended_at,
|
|
288
|
+
ended_monotonic_ns: ended_monotonic_ns,
|
|
289
|
+
status: status,
|
|
290
|
+
**@limits.counters
|
|
291
|
+
)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def summary_status(requested)
|
|
295
|
+
return :aborted if requested == :aborted
|
|
296
|
+
return :degraded if requested == :degraded || @limits.counters[:internal_errors].positive?
|
|
297
|
+
|
|
298
|
+
:completed
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def normalize_status(value)
|
|
302
|
+
normalized = value.is_a?(String) || value.is_a?(Symbol) ? value.to_sym : nil
|
|
303
|
+
return normalized if SessionSummary::STATUSES.include?(normalized)
|
|
304
|
+
|
|
305
|
+
raise RuntimeContractError, "status must be one of: #{SessionSummary::STATUSES.join(', ')}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def end_reserve_bytes
|
|
309
|
+
outcome = MAX_COUNTER / OUTCOME_COUNTERS
|
|
310
|
+
summary = SessionSummary.new(
|
|
311
|
+
ended_at: session.started_at,
|
|
312
|
+
ended_monotonic_ns: MAX_COUNTER,
|
|
313
|
+
status: :completed,
|
|
314
|
+
events_observed: outcome * OUTCOME_COUNTERS,
|
|
315
|
+
events_emitted: outcome,
|
|
316
|
+
sampled_out: outcome,
|
|
317
|
+
rate_limited: outcome,
|
|
318
|
+
session_event_limited: outcome,
|
|
319
|
+
session_byte_limited: outcome,
|
|
320
|
+
oversize: outcome,
|
|
321
|
+
internal_errors: MAX_COUNTER
|
|
322
|
+
)
|
|
323
|
+
record = JSONL::Schema.end_record(session_id: session.id, sequence: MAX_COUNTER, summary: summary)
|
|
324
|
+
writer.prepare(record).bytesize
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def state?(expected)
|
|
328
|
+
@mutex.synchronize { @state == expected }
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
# rubocop:enable Metrics/ClassLength
|
|
332
|
+
end
|
|
333
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
require_relative 'location'
|
|
5
|
+
require_relative 'policy'
|
|
6
|
+
|
|
7
|
+
module FiberAudit
|
|
8
|
+
module Runtime
|
|
9
|
+
class Redactor
|
|
10
|
+
REDACTED = '[redacted]'
|
|
11
|
+
EXTERNAL = '[external]'
|
|
12
|
+
WINDOWS_ABSOLUTE = %r{\A[A-Za-z]:/}
|
|
13
|
+
|
|
14
|
+
attr_reader :root, :policy
|
|
15
|
+
|
|
16
|
+
def initialize(root:, policy: Policy.new)
|
|
17
|
+
raise RuntimeContractError, 'policy must be a FiberAudit::Runtime::Policy' unless policy.is_a?(Policy)
|
|
18
|
+
|
|
19
|
+
safe_root = Validation.string(root, 'root', max_bytes: 4_096)
|
|
20
|
+
@root = canonical_root(safe_root).freeze
|
|
21
|
+
@policy = policy
|
|
22
|
+
freeze
|
|
23
|
+
rescue ArgumentError
|
|
24
|
+
raise RuntimeContractError, 'root is invalid'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def location(path:, line: nil, column: nil)
|
|
28
|
+
Location.new(path: redact_path(path), line: line, column: column)
|
|
29
|
+
rescue RuntimeContractError
|
|
30
|
+
Location.new(path: REDACTED, line: nil, column: nil)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def operation(value)
|
|
34
|
+
return if value.nil?
|
|
35
|
+
|
|
36
|
+
text = value.is_a?(String) || value.is_a?(Symbol) ? value.to_s : nil
|
|
37
|
+
return REDACTED unless text&.valid_encoding? && text.bytesize <= 256
|
|
38
|
+
return REDACTED if text.match?(Validation::CONTROL)
|
|
39
|
+
return REDACTED unless text.match?(Validation::OPERATION)
|
|
40
|
+
|
|
41
|
+
text.dup.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def canonical_root(value)
|
|
47
|
+
normalized = value.tr('\\', '/')
|
|
48
|
+
return Pathname.new(normalized).cleanpath.to_s if windows_absolute?(normalized)
|
|
49
|
+
|
|
50
|
+
Pathname.new(normalized).expand_path.cleanpath.to_s.tr('\\', '/')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def redact_path(value)
|
|
54
|
+
return REDACTED unless value.is_a?(String) && value.valid_encoding? && !value.empty?
|
|
55
|
+
return REDACTED if value.bytesize > Location::MAX_PATH_BYTES || value.match?(Validation::CONTROL)
|
|
56
|
+
|
|
57
|
+
normalized = value.tr('\\', '/')
|
|
58
|
+
return absolute_path(normalized) if windows_absolute?(normalized)
|
|
59
|
+
|
|
60
|
+
path = Pathname.new(normalized)
|
|
61
|
+
return relative_path(path) unless path.absolute?
|
|
62
|
+
|
|
63
|
+
absolute_path(path.expand_path.cleanpath.to_s.tr('\\', '/'))
|
|
64
|
+
rescue ArgumentError
|
|
65
|
+
REDACTED
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def absolute_path(path)
|
|
69
|
+
clean = Pathname.new(path).cleanpath.to_s.tr('\\', '/')
|
|
70
|
+
return EXTERNAL unless inside_root?(clean)
|
|
71
|
+
|
|
72
|
+
clean[(root.length + 1)..]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def relative_path(path)
|
|
76
|
+
clean = path.cleanpath
|
|
77
|
+
value = clean.to_s
|
|
78
|
+
return EXTERNAL if value == '..' || value.start_with?('../')
|
|
79
|
+
return REDACTED if value == '.'
|
|
80
|
+
|
|
81
|
+
value
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def inside_root?(path)
|
|
85
|
+
candidate, boundary = comparable_paths(path)
|
|
86
|
+
candidate.start_with?("#{boundary}/")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def comparable_paths(path)
|
|
90
|
+
if windows_absolute?(path) && windows_absolute?(root)
|
|
91
|
+
[path.downcase, root.downcase]
|
|
92
|
+
else
|
|
93
|
+
[path, root]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def windows_absolute?(path)
|
|
98
|
+
path.match?(WINDOWS_ABSOLUTE)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|