auditea 0.1.0.beta.2 → 0.1.0.beta.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42597f9a8f1014e6c58655f23f96fea08309ceeebfd1537bb346b3a418124279
4
- data.tar.gz: 8a360c4240d9c1a7b38ec9ff834074e8ed7edcf91ba52628fcfc28a00f85f3ec
3
+ metadata.gz: 5459c8f22551f101e1aa2f32689bf47623c3ec2890f448ff5dd92c4980ee8c91
4
+ data.tar.gz: 2fb620c50bc457db6f85df2d1745b1c761e3141571a14451caef9080db109e7c
5
5
  SHA512:
6
- metadata.gz: cb4e6b2cb6444448ef2391a0a5321b9441e7522a0e1220a4cea089d154a66f58577a13ec245af1dfec7cd7b676bb2a2d525520a3831f4eb0bbef9e297591b995
7
- data.tar.gz: 787fa69e8fb02fafbe35bcba69b8e592fafa146f76b9cc87b3f7fc7f0e675b7d7b3660f9290ee4a475da6857a3d6e1022bb38966abbf0ebc7827760d78e05c62
6
+ metadata.gz: faf9f0876938d536fe4aad106bba8b13ee72031ecb78c14d259a9a313eb046c84601b2b0ec3af9cb510d0b9581c7eb142aea169170f364e813b79ef7ae65f32d
7
+ data.tar.gz: df66f3cab946bb98383c903d550f4f7cf842386e4fea0b2fb11b6d037b4c0a2beee1d652c3381199e9c35a30c743a17039844b68c78494429fc64d04448fbc96
data/CHANGELOG.md CHANGED
@@ -1,6 +1,50 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0.beta.2 (unreleased)
3
+ ## 0.1.0.beta.4 (unreleased)
4
+
5
+ ### Platform component inventory (AUD-PLATFORM-INVENTORY01)
6
+
7
+ - Additive optional `evidence.inventory.components[]` on `runtime.inventory` and `host.inventory` (schema_version remains 1)
8
+ - Component fields: `product`, `version`, `scope`, `observation_method`, optional `package` / `evidence`
9
+ - Scopes: `application_runtime`, `application_server`, `database_server`, `linked_library`, `host_os`, `host_component`, `reverse_proxy`, `externally_observed`
10
+ - Runtime: promote Ruby/Rails/Bundler/RubyGems/linked OpenSSL; promote Puma (and peers) from packages; PostgreSQL server via fail-open `SHOW server_version` (never host-attached)
11
+ - Host: OS/kernel components; allowlisted detectors (`openssl version`, `apache2`/`httpd -v`, `nginx -v`) with timeout, bounded I/O, no shell, no sudo
12
+ - Absent reverse proxies are simply not observed (never claimed “not installed”)
13
+
14
+ ### FIX01 hardening
15
+
16
+ - Detectors resolve allowlisted basenames from fixed trusted system directories only (realpath boundary); never `ENV["PATH"]`
17
+ - Detector evidence is basename + observation metadata only — no raw stdout/stderr
18
+ - Aggregate detector wall-clock budget; hanging children are process-group killed and reaped
19
+ - Rails `after_initialize` schedules existing background inventory refresh (does not block boot)
20
+ - PostgreSQL version uses `connection_pool.with_connection` and releases the checkout
21
+
22
+ ### FIX02 executable trust
23
+
24
+ - After realpath containment, require root-owned (uid 0) directory and executable
25
+ - Reject group/world-writable directories or executables; reject app-UID-owned material
26
+ - Ordinary `/bin` → `/usr/bin` realpath equivalence remains supported; symlink escapes fail open as not observed
27
+
28
+ ### FIX03 process lifecycle
29
+
30
+ - After `Process.spawn`, every exit path terminates/reaps the child (ensure-owned cleanup; not timeout-only)
31
+ - Fail-open cleanup tolerates ESRCH / ECHILD / EINTR; no double-wait escape
32
+
33
+ ## 0.1.0.beta.3 (unreleased)
34
+
35
+ ### Exception capture parity (AUD-ERROR-PARITY01)
36
+
37
+ - Public `Auditea.capture_exception(error, handled:, mechanism:, context:, metadata:)` → `exception.raised`
38
+ - Rails `ErrorReporter` subscriber via public `subscribe` API (Rails 7.2 / 8.1); graceful no-op when absent
39
+ - Identity-safe WeakMap dedupe of exception objects (no integer `object_id` reuse false negatives); cleared at request/job boundaries
40
+ - When `CurrentContext` is already cleared (post-unwind `application.action_dispatch`), derive method/path/request_id/controller/action from the ErrorReporter controller/request via public APIs only
41
+ - `process_action.action_controller` remains as fallback when ErrorReporter is not installed
42
+ - Structured evidence: type, message, bounded frames (≤40) with deterministic `in_app` when knowable, `handled`, `mechanism`, HTTP path/method (no query secrets), controller/action when available
43
+ - ActiveJob: ErrorReporter sources matching `active_job` are skipped when `capture_active_job` is enabled (dedicated `job.failed` path unchanged)
44
+ - Real Rails 7.2 / 8.1 integration coverage for unhandled/handled controller exceptions and ActiveJob no-duplicate behavior
45
+ - Provenance unchanged: sdk/ruby/rails versions + configured `release`
46
+
47
+ ## 0.1.0.beta.2
4
48
 
5
49
  ### Runtime / dependency inventory (opt-in, default OFF)
6
50
 
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Ruby / Rails SDK for [AudiTea](https://github.com/reznis/AudiTea) event collection.
4
4
 
5
- Version: **0.1.0.beta.2** (not published yet)
5
+ Version: **0.1.0.beta.4** (not published yet)
6
6
 
7
7
  Captures durable technical and business evidence. AudiTea **server** owns taxonomy, dictionary, human labels, vulnerability matching, correlation, and reinterpretation.
8
8
 
@@ -12,7 +12,7 @@ The SDK does **not** perform vulnerability scanning, OS package/CVE inventory, d
12
12
 
13
13
  ```ruby
14
14
  # Gemfile
15
- gem "auditea", "0.1.0.beta.2"
15
+ gem "auditea", "0.1.0.beta.4"
16
16
  ```
17
17
 
18
18
  ```bash
@@ -48,6 +48,19 @@ Without a Source token (and with the SDK enabled), capture is skipped (`not_conf
48
48
  | `capture_host_inventory` | `true` only if multi-host/container identity matters | Default OFF; enable per environment decision |
49
49
  | `capture_requests` | `false` initially | Avoid high-volume request spam on first connect |
50
50
 
51
+ Example environment (replace the secret and release id; never commit real tokens):
52
+
53
+ ```text
54
+ AUDITEA_ENDPOINT=https://auditea.ca
55
+ AUDITEA_SOURCE_TOKEN=<secret>
56
+ AUDITEA_RELEASE=<actual deployed SHA>
57
+ AUDITEA_CAPTURE_EXCEPTIONS=true
58
+ AUDITEA_CAPTURE_RUNTIME_INVENTORY=true
59
+ AUDITEA_CAPTURE_HOST_INVENTORY=true
60
+ AUDITEA_RAISE_ERRORS=false
61
+ ```
62
+
63
+
51
64
  ### Optional
52
65
 
53
66
  Async tuning (`AUDITEA_ASYNC`, batch/flush/queue/timeouts), `AUDITEA_INVENTORY_REFRESH_INTERVAL`, `AUDITEA_HOST_KEY` / `AUDITEA_HOST_LABEL`, `capture_request_params`, `raise_errors` (debug only).
@@ -114,9 +127,17 @@ Auditea.capture(
114
127
  evidence: { business: { from_status: "draft", to_status: "approved" } }
115
128
  )
116
129
 
130
+ Auditea.capture_exception(
131
+ error,
132
+ handled: true,
133
+ mechanism: "application_rescue"
134
+ )
135
+
117
136
  Auditea.log(:info, "Export finished", context: { export_id: export.id })
118
137
  ```
119
138
 
139
+ Automatic Rails exception observation uses `Rails.error` (ErrorReporter) when available, with `process_action.action_controller` as fallback. Same-exception dedupe is request/thread-local by object id so ErrorReporter and controller notifications do not double-count.
140
+
120
141
  Wire format follows AudiTea **EVENT_CONTRACT_V1**:
121
142
 
122
143
  - Bearer Source credential authentication
@@ -147,7 +168,7 @@ AUDITEA_CAPTURE_RUNTIME_INVENTORY=true
147
168
  # optional: AUDITEA_INVENTORY_REFRESH_INTERVAL=86400
148
169
  ```
149
170
 
150
- With Rails, when capture is enabled, the Railtie may emit once after `after_initialize`. Opportunistic Rack refresh only **schedules** a single-flight background job when due — collection/delivery are never inline on the request path.
171
+ With Rails, when capture is enabled, the Railtie **schedules** a single-flight background inventory refresh after `after_initialize` (boot is not blocked on detectors or database work). Opportunistic Rack refresh uses the same scheduler when due — collection/delivery are never inline on the request path.
151
172
 
152
173
  Enabling inventory reveals internal gem names to AudiTea. Keep it off unless that is intentional.
153
174
 
data/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Release: auditea 0.1.0.beta.2
1
+ # Release: auditea 0.1.0.beta.4
2
2
 
3
3
  Authoritative runbook for controlled beta publication of the Ruby SDK.
4
4
 
@@ -153,10 +153,10 @@ PR status may say `READY FOR HUMAN PUBLICATION REVIEW` while still listing exter
153
153
  ```bash
154
154
  git checkout main
155
155
  git pull
156
- # confirm VERSION and CHANGELOG still say 0.1.0.beta.2
156
+ # confirm VERSION and CHANGELOG still say 0.1.0.beta.4
157
157
  gem build auditea.gemspec
158
158
  # inspect package once more
159
- gem push auditea-0.1.0.beta.2.gem
159
+ gem push auditea-0.1.0.beta.4.gem
160
160
  ```
161
161
 
162
162
  Then mark CHANGELOG released with the publication date in a follow-up commit.
@@ -166,9 +166,9 @@ Then mark CHANGELOG released with the publication date in a follow-up commit.
166
166
  ## Post-publication verification
167
167
 
168
168
  ```bash
169
- gem install auditea -v 0.1.0.beta.2 --pre
169
+ gem install auditea -v 0.1.0.beta.4 --pre
170
170
  ruby -rauditea -e 'puts Auditea::VERSION'
171
- # expect: 0.1.0.beta.2
171
+ # expect: 0.1.0.beta.4
172
172
  ```
173
173
 
174
174
  Prefer installing the RubyGems artifact (not a git branch) into the controlled TEST app.
@@ -6,6 +6,8 @@ require "rbconfig"
6
6
  require "socket"
7
7
  require_relative "../inventory/string_bound"
8
8
  require_relative "../inventory/digest"
9
+ require_relative "../inventory/components"
10
+ require_relative "component_detectors"
9
11
 
10
12
  module Auditea
11
13
  module HostInventory
@@ -17,7 +19,7 @@ module Auditea
17
19
  MAX_FIELD_BYTES = 128
18
20
  MAX_HOST_KEY = 128
19
21
  HOST_KEY_FORMAT = /\A[A-Za-z0-9._:-]{8,128}\z/
20
- MAX_INVENTORY_BYTES = 8_192
22
+ MAX_INVENTORY_BYTES = 16_384
21
23
  OS_RELEASE_PATH = "/etc/os-release"
22
24
 
23
25
  module_function
@@ -38,11 +40,54 @@ module Auditea
38
40
  label = configured_label
39
41
  inventory["label"] = label if label
40
42
 
43
+ components = Inventory::Components.compact_list(
44
+ host_os_components(inventory["platform"]) + ComponentDetectors.collect
45
+ )
46
+ inventory["components"] = components unless components.empty?
47
+
41
48
  inventory = enforce_size_bound(inventory)
42
49
  inventory["digest"] = Inventory::DigestComputer.compute(inventory)
43
50
  inventory
44
51
  end
45
52
 
53
+ def host_os_components(platform)
54
+ list = []
55
+ os_name = platform["os_name"]
56
+ os_version = platform["os_version"]
57
+ if present?(os_name) || present?(os_version)
58
+ product =
59
+ if os_name.to_s.match?(/ubuntu/i)
60
+ "Ubuntu"
61
+ elsif os_name.to_s.match?(/debian/i)
62
+ "Debian"
63
+ elsif present?(os_name)
64
+ os_name
65
+ else
66
+ "Host OS"
67
+ end
68
+ version = present?(os_version) ? os_version : "unknown"
69
+ list << Inventory::Components.build(
70
+ product: product,
71
+ version: version,
72
+ scope: "host_os",
73
+ observation_method: "os_release"
74
+ )
75
+ end
76
+
77
+ kernel_name = platform["kernel_name"]
78
+ kernel_release = platform["kernel_release"]
79
+ if present?(kernel_release)
80
+ list << Inventory::Components.build(
81
+ product: present?(kernel_name) ? "#{kernel_name} kernel" : "Linux kernel",
82
+ version: kernel_release,
83
+ scope: "host_os",
84
+ observation_method: "uname"
85
+ )
86
+ end
87
+
88
+ list
89
+ end
90
+
46
91
  def resolve_host_key!
47
92
  configured = Auditea.configuration.host_key.to_s.strip
48
93
  configured = ENV.fetch("AUDITEA_HOST_KEY", "").to_s.strip if configured.empty?
@@ -224,6 +269,7 @@ module Auditea
224
269
  # Drop optional fields first; keep identity + required platform.
225
270
  trimmed = inventory.dup
226
271
  trimmed.delete("label")
272
+ trimmed.delete("components")
227
273
  trimmed["hardware"] = {}
228
274
  trimmed["runtime_context"] = {}
229
275
  trimmed
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Auditea
6
+ module HostInventory
7
+ # Allowlisted local component detectors.
8
+ # Fixed argv only — no shell, no sudo, no PATH search, no caller-supplied paths.
9
+ module ComponentDetectors
10
+ # Wall-clock budget for the entire collect pass (all detectors/candidates).
11
+ AGGREGATE_TIMEOUT_SECONDS = 2.5
12
+ # Per-command cap; also clipped by remaining aggregate budget.
13
+ PER_COMMAND_TIMEOUT_SECONDS = 1.0
14
+ MAX_OUTPUT_BYTES = 2_048
15
+ ALLOWED_BASENAMES = %w[openssl apache2 httpd nginx].freeze
16
+
17
+ # Fixed trusted system directories only — never ENV["PATH"] or writable app/tmp dirs.
18
+ TRUSTED_BIN_DIRS = %w[
19
+ /usr/bin
20
+ /bin
21
+ /usr/sbin
22
+ /sbin
23
+ /usr/local/bin
24
+ /usr/local/sbin
25
+ ].freeze
26
+
27
+ DETECTORS = [
28
+ {
29
+ product: "OpenSSL",
30
+ scope: "host_component",
31
+ observation_method: "host.openssl_version",
32
+ candidates: [%w[openssl version]],
33
+ parser: :parse_openssl
34
+ },
35
+ {
36
+ product: "Apache HTTP Server",
37
+ scope: "reverse_proxy",
38
+ observation_method: "host.apache_v",
39
+ candidates: [%w[apache2 -v], %w[httpd -v]],
40
+ parser: :parse_apache
41
+ },
42
+ {
43
+ product: "nginx",
44
+ scope: "reverse_proxy",
45
+ observation_method: "host.nginx_v",
46
+ candidates: [%w[nginx -v]],
47
+ parser: :parse_nginx
48
+ }
49
+ ].freeze
50
+
51
+ module_function
52
+
53
+ def collect
54
+ deadline = monotonic_now + AGGREGATE_TIMEOUT_SECONDS
55
+ DETECTORS.filter_map { |detector| detect(detector, deadline: deadline) }
56
+ rescue StandardError
57
+ []
58
+ end
59
+
60
+ def detect(detector, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
61
+ detector.fetch(:candidates).each do |argv|
62
+ break if monotonic_now >= deadline
63
+
64
+ output = capture(argv, deadline: deadline)
65
+ next if output.nil?
66
+
67
+ version = send(detector.fetch(:parser), output)
68
+ next if version.nil? || version.empty?
69
+
70
+ return Inventory::Components.build(
71
+ product: detector.fetch(:product),
72
+ version: version,
73
+ scope: detector.fetch(:scope),
74
+ observation_method: detector.fetch(:observation_method),
75
+ evidence: {
76
+ "command" => argv.first.to_s
77
+ }
78
+ )
79
+ end
80
+ nil
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ # Resolve basename from trusted directories only; execute absolute path + fixed args.
86
+ def capture(argv, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
87
+ basename = argv.first.to_s
88
+ return nil unless ALLOWED_BASENAMES.include?(basename)
89
+
90
+ remaining = deadline - monotonic_now
91
+ return nil if remaining <= 0
92
+
93
+ executable = resolve_executable(basename)
94
+ return nil unless executable
95
+
96
+ full_argv = [executable] + argv.drop(1).map(&:to_s)
97
+ timeout_seconds = [PER_COMMAND_TIMEOUT_SECONDS, remaining].min
98
+ return nil if timeout_seconds <= 0
99
+
100
+ run_bounded(full_argv, timeout_seconds)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def resolve_executable(basename)
106
+ return nil unless ALLOWED_BASENAMES.include?(basename)
107
+ return nil if basename.include?("/") || basename.include?("\\") || basename.include?("\0")
108
+
109
+ TRUSTED_BIN_DIRS.each do |dir|
110
+ next if dir.empty? || dir.include?("\0")
111
+
112
+ candidate = File.join(dir, basename)
113
+ next unless File.file?(candidate)
114
+ next unless File.executable?(candidate)
115
+
116
+ real_dir = begin
117
+ File.realpath(dir)
118
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
119
+ next
120
+ end
121
+
122
+ real_file = begin
123
+ File.realpath(candidate)
124
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
125
+ next
126
+ end
127
+
128
+ # Symlinks must not escape the trusted real directory boundary
129
+ # (supports ordinary /bin -> /usr/bin realpath equivalence).
130
+ next unless File.dirname(real_file) == real_dir
131
+ next unless trusted_material?(real_dir, real_file)
132
+
133
+ return real_file
134
+ end
135
+ nil
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ # Trust is ownership + mode on the resolved real paths — not directory-name
141
+ # membership alone. Fail open (reject) when trust cannot be established.
142
+ #
143
+ # Policy:
144
+ # - material must be root-owned (uid 0); app-UID ownership is never enough
145
+ # - directory and executable must not be group- or world-writable
146
+ # - caller-configurable paths are never accepted
147
+ def trusted_material?(real_dir, real_file)
148
+ dir_stat = stat_path(real_dir)
149
+ file_stat = stat_path(real_file)
150
+ return false unless dir_stat && file_stat
151
+
152
+ trusted_stats?(dir_stat, file_stat)
153
+ rescue StandardError
154
+ false
155
+ end
156
+
157
+ def trusted_stats?(dir_stat, file_stat)
158
+ return false unless root_owned?(dir_stat) && root_owned?(file_stat)
159
+ return false if group_or_world_writable?(dir_stat)
160
+ return false if group_or_world_writable?(file_stat)
161
+ return false if app_user_owned_untrusted?(dir_stat)
162
+ return false if app_user_owned_untrusted?(file_stat)
163
+
164
+ true
165
+ end
166
+
167
+ def root_owned?(stat)
168
+ stat.uid.zero?
169
+ end
170
+
171
+ # Root-owned system material remains trusted even when the process euid is 0.
172
+ # Non-root owners (including the effective application user) are rejected.
173
+ def app_user_owned_untrusted?(stat)
174
+ return false if stat.uid.zero?
175
+
176
+ stat.uid == Process.euid
177
+ end
178
+
179
+ def group_or_world_writable?(stat)
180
+ (stat.mode & 0o022).nonzero?
181
+ end
182
+
183
+ def stat_path(path)
184
+ File.stat(path)
185
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
186
+ nil
187
+ end
188
+
189
+ def run_bounded(full_argv, timeout_seconds)
190
+ out_r, out_w = IO.pipe
191
+ err_r, err_w = IO.pipe
192
+ pid = nil
193
+
194
+ begin
195
+ pid = Process.spawn(
196
+ *full_argv,
197
+ in: :close,
198
+ out: out_w,
199
+ err: err_w,
200
+ pgroup: true,
201
+ close_others: true
202
+ )
203
+ out_w.close
204
+ err_w.close
205
+ out_w = err_w = nil
206
+
207
+ stdout = ""
208
+ stderr = ""
209
+ status = nil
210
+
211
+ Timeout.timeout(timeout_seconds) do
212
+ stdout = out_r.read(MAX_OUTPUT_BYTES).to_s
213
+ stderr = err_r.read(MAX_OUTPUT_BYTES).to_s
214
+ _pid, status = Process.wait2(pid)
215
+ pid = nil
216
+ end
217
+
218
+ combined = "#{stdout}\n#{stderr}".byteslice(0, MAX_OUTPUT_BYTES).to_s
219
+ return nil if combined.strip.empty?
220
+ return nil if status && !status.success? && combined !~ /version|Server version|nginx/i
221
+
222
+ combined
223
+ rescue Timeout::Error
224
+ nil
225
+ ensure
226
+ # Any post-spawn exit path (timeout, other exceptions, early return after wait)
227
+ # must account for the child while pid remains owned by this method.
228
+ if pid
229
+ begin
230
+ reap_process_group(pid)
231
+ rescue StandardError
232
+ nil
233
+ end
234
+ pid = nil
235
+ end
236
+
237
+ begin
238
+ out_w&.close
239
+ rescue StandardError
240
+ nil
241
+ end
242
+ begin
243
+ err_w&.close
244
+ rescue StandardError
245
+ nil
246
+ end
247
+ begin
248
+ out_r.close
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ begin
253
+ err_r.close
254
+ rescue StandardError
255
+ nil
256
+ end
257
+ end
258
+ end
259
+
260
+ def reap_process_group(pid)
261
+ return unless pid
262
+
263
+ begin
264
+ Process.kill("KILL", -pid)
265
+ rescue Errno::ESRCH, Errno::EPERM
266
+ begin
267
+ Process.kill("KILL", pid)
268
+ rescue Errno::ESRCH, Errno::EPERM
269
+ nil
270
+ end
271
+ end
272
+
273
+ loop do
274
+ Process.waitpid(pid)
275
+ break
276
+ rescue Errno::ECHILD
277
+ break
278
+ rescue Errno::EINTR
279
+ # Child may still be exiting; retry wait.
280
+ next
281
+ end
282
+ rescue StandardError
283
+ nil
284
+ end
285
+
286
+ def parse_openssl(output)
287
+ # OpenSSL 3.0.2 15 Mar 2022
288
+ if (match = output.match(/OpenSSL\s+([0-9][^\s]*)/i))
289
+ match[1]
290
+ end
291
+ end
292
+
293
+ def parse_apache(output)
294
+ # Server version: Apache/2.4.52 (Ubuntu)
295
+ if (match = output.match(%r{Apache/([0-9][^\s)]*)}i))
296
+ match[1]
297
+ end
298
+ end
299
+
300
+ def parse_nginx(output)
301
+ # nginx version: nginx/1.18.0 (Ubuntu)
302
+ if (match = output.match(%r{nginx/([0-9][^\s)]*)}i))
303
+ match[1]
304
+ end
305
+ end
306
+
307
+ def monotonic_now
308
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
309
+ end
310
+ end
311
+ end
312
+ end
@@ -23,10 +23,13 @@ module Auditea
23
23
  end
24
24
 
25
25
  ::ActiveJob::Base.around_perform do |job, block|
26
+ Auditea::Instrumentation::Exception::Deduper.clear!
26
27
  block.call
27
28
  rescue StandardError => error
28
29
  Auditea::Instrumentation::ActiveJob.report_unhandled(job, error)
29
30
  raise
31
+ ensure
32
+ Auditea::Instrumentation::Exception::Deduper.clear!
30
33
  end
31
34
  end
32
35
 
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Instrumentation
5
+ # Rails / ActiveSupport::ErrorReporter subscriber (public API only).
6
+ # Installed when Rails.error / ActiveSupport.error_reporter#subscribe exists.
7
+ class ErrorSubscriber
8
+ ACTIVE_JOB_SOURCE = /active_job/i
9
+
10
+ class << self
11
+ def install!
12
+ return if @installed
13
+
14
+ reporter = resolve_reporter
15
+ return unless reporter.respond_to?(:subscribe)
16
+
17
+ @subscriber = new
18
+ reporter.subscribe(@subscriber)
19
+ @installed = true
20
+ true
21
+ rescue StandardError => error
22
+ Auditea.handle_error(error)
23
+ false
24
+ end
25
+
26
+ def installed?
27
+ !!@installed
28
+ end
29
+
30
+ def reset_for_tests!
31
+ reporter = resolve_reporter
32
+ reporter.unsubscribe(@subscriber) if @subscriber && reporter.respond_to?(:unsubscribe)
33
+ @subscriber = nil
34
+ @installed = false
35
+ end
36
+
37
+ def resolve_reporter
38
+ if defined?(::Rails) && ::Rails.respond_to?(:error)
39
+ ::Rails.error
40
+ elsif defined?(::ActiveSupport) && ::ActiveSupport.respond_to?(:error_reporter)
41
+ ::ActiveSupport.error_reporter
42
+ end
43
+ end
44
+ end
45
+
46
+ # ActiveSupport::ErrorReporter subscriber contract — must never raise.
47
+ def report(error, handled:, severity: :error, context: {}, source: nil)
48
+ return unless Auditea.configuration.capture_exceptions
49
+ return if skip_active_job?(source)
50
+
51
+ structural = StructuralRequest.from_reporter_context(context)
52
+
53
+ Auditea::Instrumentation::Exception.capture(
54
+ error,
55
+ handled: handled,
56
+ mechanism: mechanism_for(source),
57
+ severity: map_severity(severity, handled),
58
+ evidence: structural["evidence"],
59
+ context: structural["context"],
60
+ metadata: {
61
+ "sdk_event_kind" => "auto_observed"
62
+ }
63
+ )
64
+ rescue StandardError => error
65
+ Auditea.handle_error(error)
66
+ end
67
+
68
+ private
69
+
70
+ def skip_active_job?(source)
71
+ return false unless source.to_s.match?(ACTIVE_JOB_SOURCE)
72
+ return true if Auditea.configuration.capture_active_job
73
+
74
+ false
75
+ end
76
+
77
+ def mechanism_for(source)
78
+ value = source.to_s.strip
79
+ value.empty? ? "rails.error_reporter" : value
80
+ end
81
+
82
+ def map_severity(severity, handled)
83
+ case severity.to_s
84
+ when "warning" then "warning"
85
+ when "info" then "info"
86
+ when "error" then "error"
87
+ else
88
+ handled ? "warning" : "error"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end