auditea 0.1.0.beta.1 → 0.1.0.beta.3

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.
@@ -2,28 +2,56 @@
2
2
 
3
3
  module Auditea
4
4
  module Instrumentation
5
+ # Structured exception → `exception.raised` evidence.
6
+ #
7
+ # Capture paths (fail-open at the Auditea boundary):
8
+ # - explicit Auditea.capture_exception
9
+ # - Rails ErrorReporter subscriber (preferred when available)
10
+ # - process_action.action_controller fallback when ErrorReporter is absent
5
11
  module Exception
12
+ MAX_FRAMES = 40
13
+ MESSAGE_MAX = 500
14
+
6
15
  module_function
7
16
 
17
+ def capture(error, handled: false, mechanism: nil, severity: "error",
18
+ context: nil, metadata: nil, evidence: nil, claim: true)
19
+ return { ok: false, skipped: true, reason: "disabled" } unless Auditea.configuration.capture_exceptions
20
+ return { ok: false, skipped: true, reason: "not_an_exception" } unless error.is_a?(::Exception)
21
+ return { ok: false, skipped: true, reason: "duplicate" } if claim && !Deduper.claim!(error)
22
+
23
+ merged_evidence = merge_evidence(error, handled: handled, mechanism: mechanism, evidence: evidence)
24
+ meta = { "sdk_event_kind" => "auto_observed" }
25
+ meta.merge!(stringify_keys(metadata)) if metadata
26
+
27
+ Auditea.capture(
28
+ "exception.raised",
29
+ category: "error",
30
+ severity: severity || "error",
31
+ evidence: merged_evidence,
32
+ context: context,
33
+ metadata: meta
34
+ )
35
+ rescue StandardError => error
36
+ Auditea.handle_error(error)
37
+ end
38
+
8
39
  def capture_from_notification(event)
40
+ # Prefer Rails ErrorReporter when installed so handled/source semantics stay
41
+ # consistent and ActionDispatch does not double-emit with process_action.
42
+ return if ErrorSubscriber.installed?
43
+
9
44
  payload = event.payload || {}
10
45
  exception = payload[:exception_object]
11
46
  exception ||= build_from_array(payload[:exception])
12
47
  return unless exception
13
48
 
14
- frames = safe_frames(exception)
15
49
  path = payload[:path].to_s.split("?", 2).first
16
-
17
- Auditea.capture(
18
- "exception.raised",
19
- category: "error",
20
- severity: "error",
50
+ capture(
51
+ exception,
52
+ handled: false,
53
+ mechanism: "action_controller.process_action",
21
54
  evidence: {
22
- "exception" => {
23
- "type" => exception.class.name,
24
- "message" => Sanitizer.truncate_string(exception.message.to_s, 500),
25
- "frames" => frames
26
- }.compact,
27
55
  "http" => {
28
56
  "method" => payload[:method],
29
57
  "path" => path,
@@ -33,15 +61,46 @@ module Auditea
33
61
  "controller" => payload[:controller],
34
62
  "action" => payload[:action]
35
63
  }.compact
36
- },
37
- metadata: {
38
- "sdk_event_kind" => "auto_observed"
39
64
  }
40
65
  )
41
66
  rescue StandardError => error
42
67
  Auditea.handle_error(error)
43
68
  end
44
69
 
70
+ def merge_evidence(error, handled:, mechanism:, evidence:)
71
+ base = stringify_keys(evidence || {})
72
+ exception = base["exception"].is_a?(Hash) ? base["exception"].dup : {}
73
+ exception["type"] ||= error.class.name
74
+ exception["message"] ||= Sanitizer.truncate_string(error.message.to_s, MESSAGE_MAX)
75
+ exception["frames"] ||= safe_frames(error)
76
+ exception["handled"] = handled ? true : false unless exception.key?("handled")
77
+ exception["mechanism"] = mechanism.to_s if mechanism && !exception.key?("mechanism")
78
+
79
+ http = base["http"].is_a?(Hash) ? base["http"].dup : {}
80
+ enrich_http_from_context!(http)
81
+
82
+ rails = base["rails"].is_a?(Hash) ? base["rails"].dup : {}
83
+
84
+ {
85
+ "exception" => exception.compact,
86
+ "http" => compact_hash(http),
87
+ "rails" => compact_hash(rails)
88
+ }.compact.merge(base.except("exception", "http", "rails"))
89
+ end
90
+
91
+ def compact_hash(hash)
92
+ cleaned = hash.compact
93
+ cleaned.empty? ? nil : cleaned
94
+ end
95
+
96
+ def enrich_http_from_context!(http)
97
+ ctx = CurrentContext.to_h
98
+ http_ctx = ctx["http"].is_a?(Hash) ? ctx["http"] : {}
99
+ http["method"] ||= http_ctx["method"]
100
+ path = http_ctx["path"].to_s.split("?", 2).first
101
+ http["path"] ||= path unless path.nil? || path.empty?
102
+ end
103
+
45
104
  def build_from_array(pair)
46
105
  return nil unless pair.is_a?(Array) && pair.size >= 2
47
106
 
@@ -56,9 +115,12 @@ module Auditea
56
115
  def safe_frames(exception)
57
116
  return [] unless exception.respond_to?(:backtrace) && exception.backtrace
58
117
 
59
- exception.backtrace.first(20).map do |line|
118
+ exception.backtrace.first(MAX_FRAMES).map do |line|
60
119
  file, linenum, method_name = parse_frame(line)
61
- { "file" => file, "line" => linenum, "method" => method_name }.compact
120
+ frame = { "file" => file, "line" => linenum, "method" => method_name }.compact
121
+ in_app = classify_in_app(file)
122
+ frame["in_app"] = in_app unless in_app.nil?
123
+ frame
62
124
  end
63
125
  end
64
126
 
@@ -70,6 +132,62 @@ module Auditea
70
132
  [line, nil, nil]
71
133
  end
72
134
  end
135
+
136
+ # Deterministic app-vs-framework marking. Returns nil when unknown (omit key).
137
+ def classify_in_app(file)
138
+ path = file.to_s
139
+ return nil if path.empty?
140
+
141
+ return true if path.start_with?("app/") || path.include?("/app/")
142
+ return false if path.include?("/gems/") || path.include?("/ruby/") ||
143
+ path.include?("/vendor/bundle/") || path.include?("/lib/ruby/")
144
+
145
+ if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
146
+ root = ::Rails.root.to_s
147
+ if path.start_with?(root)
148
+ return false if path.start_with?("#{root}/vendor")
149
+
150
+ return true
151
+ end
152
+ end
153
+
154
+ nil
155
+ end
156
+
157
+ def stringify_keys(value)
158
+ EventBuilder.stringify_keys(value)
159
+ end
160
+
161
+ # Identity-safe claim set: WeakMap keys are the exception objects themselves.
162
+ # GC removes entries when exceptions are collected, so recycled object_ids cannot
163
+ # falsely suppress a distinct raise. Still cleared at request/job boundaries.
164
+ module Deduper
165
+ class << self
166
+ def claim!(error)
167
+ map = store
168
+ return false if map.key?(error)
169
+
170
+ map[error] = true
171
+ true
172
+ end
173
+
174
+ def claimed?(error)
175
+ store.key?(error)
176
+ rescue StandardError
177
+ false
178
+ end
179
+
180
+ def clear!
181
+ Thread.current[:auditea_exception_claims] = ObjectSpace::WeakMap.new
182
+ end
183
+
184
+ private
185
+
186
+ def store
187
+ Thread.current[:auditea_exception_claims] ||= ObjectSpace::WeakMap.new
188
+ end
189
+ end
190
+ end
73
191
  end
74
192
  end
75
193
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Instrumentation
5
+ # Derive scalar request/controller fields from Rails ErrorReporter context.
6
+ # Never retains controller/request objects; never reads params/body/cookies/session.
7
+ module StructuralRequest
8
+ module_function
9
+
10
+ def from_reporter_context(context)
11
+ hash = context.is_a?(Hash) ? context : {}
12
+ controller = hash[:controller] || hash["controller"]
13
+
14
+ rails = {}
15
+ http = {}
16
+ event_context = {}
17
+
18
+ if controller && !controller.is_a?(String) && controller.respond_to?(:class)
19
+ rails["controller"] = controller.class.name
20
+ if controller.respond_to?(:action_name)
21
+ action = controller.action_name
22
+ rails["action"] = action.to_s if present?(action)
23
+ end
24
+
25
+ request = safe_request(controller)
26
+ if request
27
+ method = safe_request_method(request)
28
+ http["method"] = method if present?(method)
29
+
30
+ path = safe_path(request)
31
+ http["path"] = path if present?(path)
32
+
33
+ request_id = safe_request_id(request)
34
+ event_context["request_id"] = request_id if present?(request_id)
35
+ end
36
+
37
+ status = safe_status(controller)
38
+ http["status"] = status unless status.nil?
39
+ end
40
+
41
+ {
42
+ "evidence" => {
43
+ "rails" => compact_hash(rails),
44
+ "http" => compact_hash(http)
45
+ }.compact,
46
+ "context" => compact_hash(event_context)
47
+ }
48
+ rescue StandardError
49
+ { "evidence" => {}, "context" => {} }
50
+ end
51
+
52
+ def safe_request(controller)
53
+ return nil unless controller.respond_to?(:request)
54
+
55
+ request = controller.request
56
+ return nil if request.nil?
57
+ return nil unless request.respond_to?(:request_method) || request.respond_to?(:path)
58
+
59
+ request
60
+ rescue StandardError
61
+ nil
62
+ end
63
+
64
+ def safe_request_method(request)
65
+ return request.request_method.to_s if request.respond_to?(:request_method)
66
+
67
+ nil
68
+ rescue StandardError
69
+ nil
70
+ end
71
+
72
+ def safe_path(request)
73
+ raw =
74
+ if request.respond_to?(:path)
75
+ request.path
76
+ elsif request.respond_to?(:fullpath)
77
+ request.fullpath
78
+ end
79
+ return nil unless present?(raw)
80
+
81
+ raw.to_s.split("?", 2).first
82
+ rescue StandardError
83
+ nil
84
+ end
85
+
86
+ def safe_request_id(request)
87
+ if request.respond_to?(:request_id)
88
+ value = request.request_id
89
+ return value.to_s if present?(value)
90
+ end
91
+
92
+ if request.respond_to?(:env)
93
+ env = request.env
94
+ if env.is_a?(Hash)
95
+ value = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
96
+ return value.to_s if present?(value)
97
+ end
98
+ end
99
+
100
+ nil
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def safe_status(controller)
106
+ return nil unless controller.respond_to?(:response)
107
+
108
+ response = controller.response
109
+ return nil unless response
110
+ return nil unless response.respond_to?(:status)
111
+
112
+ status = response.status
113
+ return nil unless status.is_a?(Integer)
114
+ return nil unless status.between?(100, 599)
115
+
116
+ # Unhandled failures often still have the default 200 before rendering;
117
+ # only keep status when the response looks finalized or already erroneous.
118
+ committed = response.respond_to?(:committed?) && response.committed?
119
+ return status if committed || status >= 400
120
+
121
+ nil
122
+ rescue StandardError
123
+ nil
124
+ end
125
+
126
+ def compact_hash(hash)
127
+ cleaned = hash.compact
128
+ cleaned.empty? ? nil : cleaned
129
+ end
130
+
131
+ def present?(value)
132
+ !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # Process-local, single-flight background refresh for runtime.inventory.
6
+ # Middleware only schedules; collection/delivery never run inline on the Rack path.
7
+ module BackgroundRefresh
8
+ module_function
9
+
10
+ def schedule!
11
+ return false unless Auditea.configuration.capture_runtime_inventory?
12
+ return false unless Emitter.collection_due?
13
+ return false unless claim_slot!
14
+
15
+ thread = Thread.new { run_capture }
16
+ thread.name = "auditea-runtime-inventory" if thread.respond_to?(:name=)
17
+ true
18
+ rescue StandardError => error
19
+ release_slot!
20
+ Auditea.handle_error(error)
21
+ false
22
+ end
23
+
24
+ def reset!
25
+ state_mutex.synchronize do
26
+ @inflight = false
27
+ end
28
+ end
29
+
30
+ def inflight?
31
+ state_mutex.synchronize { !!@inflight }
32
+ end
33
+
34
+ def claim_slot!
35
+ state_mutex.synchronize do
36
+ return false if @inflight
37
+
38
+ @inflight = true
39
+ true
40
+ end
41
+ end
42
+ private_class_method :claim_slot!
43
+
44
+ def release_slot!
45
+ state_mutex.synchronize { @inflight = false }
46
+ end
47
+ private_class_method :release_slot!
48
+
49
+ def run_capture
50
+ Emitter.capture!(force: false)
51
+ rescue StandardError => error
52
+ Auditea.handle_error(error)
53
+ ensure
54
+ release_slot!
55
+ end
56
+ private_class_method :run_capture
57
+
58
+ def state_mutex
59
+ @state_mutex ||= Mutex.new
60
+ end
61
+ private_class_method :state_mutex
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require_relative "string_bound"
6
+
7
+ module Auditea
8
+ module Inventory
9
+ # Collects a normalized runtime + dependency inventory for AudiTea.
10
+ # Never includes filesystem paths, remote URLs, tokens, or env vars.
11
+ module Collector
12
+ SCHEMA_VERSION = 1
13
+ MAX_PACKAGES = 400
14
+ MAX_FIELD_BYTES = 128
15
+ MAX_REQUIREMENT_BYTES = 256
16
+ # Leave headroom under Transport::Limits::SINGLE_MAX_BYTES after envelope/provenance.
17
+ MAX_INVENTORY_BYTES = 110_000
18
+
19
+ module_function
20
+
21
+ def collect
22
+ packages, completeness, packages_total = collect_packages
23
+ inventory = {
24
+ "schema_version" => SCHEMA_VERSION,
25
+ "completeness" => completeness,
26
+ "runtime" => runtime_metadata,
27
+ "package_manager" => package_manager_metadata,
28
+ "openssl" => openssl_metadata,
29
+ "sdk" => {
30
+ "name" => "auditea",
31
+ "version" => VERSION
32
+ },
33
+ "packages" => packages,
34
+ "package_count" => packages.size
35
+ }
36
+ inventory["packages_total"] = packages_total if packages_total && packages_total != packages.size
37
+
38
+ framework = framework_metadata
39
+ inventory["framework"] = framework if framework
40
+
41
+ release = Auditea.configuration.release
42
+ inventory["application"] = { "release" => bound_string(release) } if present?(release)
43
+
44
+ inventory = enforce_size_bound(inventory)
45
+ inventory["digest"] = DigestComputer.compute(inventory)
46
+ inventory
47
+ end
48
+
49
+ def runtime_metadata
50
+ {
51
+ "language" => "ruby",
52
+ "engine" => bound_string(RUBY_ENGINE),
53
+ "version" => bound_string(RUBY_VERSION),
54
+ "patchlevel" => bound_string(RUBY_PATCHLEVEL.to_s),
55
+ "platform" => bound_string(RUBY_PLATFORM),
56
+ "gem_platform" => bound_string(Gem::Platform.local.to_s)
57
+ }
58
+ end
59
+
60
+ def framework_metadata
61
+ return nil unless defined?(::Rails)
62
+
63
+ version =
64
+ if defined?(::Rails::VERSION::STRING)
65
+ ::Rails::VERSION::STRING
66
+ elsif ::Rails.respond_to?(:version)
67
+ ::Rails.version
68
+ end
69
+ return nil unless present?(version)
70
+
71
+ { "name" => "rails", "version" => bound_string(version) }
72
+ end
73
+
74
+ def package_manager_metadata
75
+ meta = { "rubygems_version" => bound_string(Gem::VERSION) }
76
+ meta["bundler_version"] = bound_string(::Bundler::VERSION) if defined?(::Bundler::VERSION)
77
+ meta
78
+ end
79
+
80
+ def openssl_metadata
81
+ meta = {}
82
+ meta["extension_version"] = bound_string(::OpenSSL::VERSION) if defined?(::OpenSSL::VERSION)
83
+ if defined?(::OpenSSL) && ::OpenSSL.const_defined?(:OPENSSL_VERSION)
84
+ meta["build_version"] = bound_string(::OpenSSL::OPENSSL_VERSION)
85
+ end
86
+ if defined?(::OpenSSL) && ::OpenSSL.const_defined?(:OPENSSL_LIBRARY_VERSION)
87
+ meta["library_version"] = bound_string(::OpenSSL::OPENSSL_LIBRARY_VERSION)
88
+ end
89
+ meta
90
+ end
91
+
92
+ def collect_packages
93
+ locked = current_platform_bundle_packages
94
+ return finalize_packages(locked, "bundle_locked") if locked
95
+
96
+ loaded = loaded_gem_packages
97
+ finalize_packages(loaded, "loaded_only")
98
+ end
99
+
100
+ def finalize_packages(packages, completeness)
101
+ packages = Array(packages)
102
+ packages = sort_packages(packages)
103
+ total = packages.size
104
+ if total > MAX_PACKAGES
105
+ packages = packages.first(MAX_PACKAGES)
106
+ completeness = "truncated"
107
+ end
108
+ [packages, completeness, total]
109
+ end
110
+
111
+ # Prefer Bundler.definition.specs — the materialized SpecSet for the
112
+ # current platform / force_ruby_platform — not every lockfile platform variant.
113
+ # Only claim bundle_locked when every materialized spec normalizes and the
114
+ # direct-dependency map is available; otherwise return nil → loaded_only.
115
+ def current_platform_bundle_packages
116
+ return nil unless defined?(::Bundler)
117
+ return nil unless ::Bundler.respond_to?(:definition)
118
+
119
+ definition = ::Bundler.definition
120
+ return nil if definition.nil?
121
+ return nil unless definition.respond_to?(:specs)
122
+
123
+ specs = Array(definition.specs)
124
+ return nil if specs.empty?
125
+
126
+ directs = direct_dependency_map
127
+ return nil if directs.nil?
128
+
129
+ packages = []
130
+ specs.each do |spec|
131
+ pkg = package_from_spec(spec, directs)
132
+ # Do not silently omit a failed spec while still claiming bundle_locked.
133
+ return nil if pkg.nil?
134
+
135
+ packages << pkg
136
+ end
137
+ return nil if packages.empty?
138
+
139
+ packages
140
+ rescue StandardError
141
+ nil
142
+ end
143
+
144
+ def loaded_gem_packages
145
+ directs = direct_dependency_map || {}
146
+ Gem.loaded_specs.values.filter_map { |spec| package_from_spec(spec, directs) }
147
+ rescue StandardError
148
+ []
149
+ end
150
+
151
+ # Returns a Hash of direct deps, or nil when the map cannot be obtained.
152
+ # Callers that claim bundle_locked must treat nil as failure (do not invent
153
+ # direct: false for every package).
154
+ def direct_dependency_map
155
+ return nil unless defined?(::Bundler)
156
+ return nil unless ::Bundler.respond_to?(:definition)
157
+
158
+ definition = ::Bundler.definition
159
+ return nil if definition.nil?
160
+ return nil unless definition.respond_to?(:dependencies)
161
+
162
+ definition.dependencies.each_with_object({}) do |dep, memo|
163
+ name = dep.name.to_s
164
+ next if name.empty?
165
+
166
+ memo[name] = {
167
+ "direct" => true,
168
+ "requirement" => bound_string(dep.requirement.to_s, MAX_REQUIREMENT_BYTES)
169
+ }
170
+ end
171
+ rescue StandardError
172
+ nil
173
+ end
174
+
175
+ def package_from_spec(spec, directs)
176
+ name = bound_string(spec.name)
177
+ return nil if name.nil? || name.empty?
178
+
179
+ version = bound_string(spec.version.to_s)
180
+ return nil if version.nil? || version.empty?
181
+
182
+ platform = normalize_platform(spec)
183
+ entry = {
184
+ "ecosystem" => "RubyGems",
185
+ "name" => name,
186
+ "version" => version,
187
+ "platform" => platform,
188
+ "direct" => false,
189
+ "source_type" => classify_source(spec)
190
+ }
191
+
192
+ direct = directs[name]
193
+ if direct
194
+ entry["direct"] = true
195
+ entry["requirement"] = direct["requirement"] if present?(direct["requirement"])
196
+ end
197
+
198
+ revision = git_revision(spec)
199
+ entry["revision"] = bound_string(revision) if present?(revision)
200
+
201
+ entry
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
206
+ def normalize_platform(spec)
207
+ platform =
208
+ if spec.respond_to?(:platform) && spec.platform
209
+ spec.platform.to_s
210
+ else
211
+ "ruby"
212
+ end
213
+ platform = "ruby" if platform.empty?
214
+ bound_string(platform)
215
+ end
216
+
217
+ def classify_source(spec)
218
+ source = spec.respond_to?(:source) ? spec.source : nil
219
+ return "unknown" if source.nil?
220
+
221
+ class_name = source.class.name.to_s
222
+ case class_name
223
+ when /Source::Rubygems\z/, /RubygemsRemote\z/
224
+ "rubygems"
225
+ when /Source::Git\z/
226
+ "git"
227
+ when /Source::Path\z/, /Source::Gemspec\z/
228
+ "path"
229
+ else
230
+ return "git" if source.respond_to?(:revision) && present?(safe_revision(source))
231
+ return "path" if source.respond_to?(:path)
232
+
233
+ "unknown"
234
+ end
235
+ rescue StandardError
236
+ "unknown"
237
+ end
238
+
239
+ def git_revision(spec)
240
+ source = spec.respond_to?(:source) ? spec.source : nil
241
+ return nil unless source
242
+
243
+ class_name = source.class.name.to_s
244
+ return nil unless class_name.include?("Git")
245
+
246
+ safe_revision(source)
247
+ rescue StandardError
248
+ nil
249
+ end
250
+
251
+ def safe_revision(source)
252
+ return nil unless source.respond_to?(:revision)
253
+
254
+ revision = source.revision
255
+ return nil unless revision.is_a?(String)
256
+ return nil unless revision.match?(/\A[0-9a-f]{7,40}\z/i)
257
+
258
+ revision.downcase
259
+ rescue StandardError
260
+ nil
261
+ end
262
+
263
+ def sort_packages(packages)
264
+ packages.sort_by { |pkg| [pkg["name"].to_s, pkg["version"].to_s, pkg["platform"].to_s] }
265
+ end
266
+
267
+ def enforce_size_bound(inventory)
268
+ json_bytes = JSON.generate(inventory_for_size(inventory)).bytesize
269
+ return inventory if json_bytes <= MAX_INVENTORY_BYTES
270
+
271
+ packages = inventory["packages"].dup
272
+ total = inventory["packages_total"] || packages.size
273
+ while packages.size > 1
274
+ candidate = inventory.merge("packages" => packages)
275
+ break if JSON.generate(inventory_for_size(candidate)).bytesize <= MAX_INVENTORY_BYTES
276
+
277
+ packages.pop
278
+ end
279
+
280
+ inventory.merge(
281
+ "packages" => packages,
282
+ "package_count" => packages.size,
283
+ "packages_total" => total,
284
+ "completeness" => "truncated"
285
+ )
286
+ end
287
+
288
+ def inventory_for_size(inventory)
289
+ inventory.except("digest")
290
+ end
291
+
292
+ def bound_string(value, max_bytes = MAX_FIELD_BYTES)
293
+ StringBound.truncate(value, max_bytes)
294
+ end
295
+
296
+ def present?(value)
297
+ !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
298
+ end
299
+ end
300
+ end
301
+ end