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.
@@ -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
@@ -3,11 +3,14 @@
3
3
  require "digest"
4
4
  require "json"
5
5
  require_relative "string_bound"
6
+ require_relative "components"
7
+ require_relative "database_server"
6
8
 
7
9
  module Auditea
8
10
  module Inventory
9
11
  # Collects a normalized runtime + dependency inventory for AudiTea.
10
12
  # Never includes filesystem paths, remote URLs, tokens, or env vars.
13
+ # rubocop:disable-next Metrics/ModuleLength -- cohesive collector + component promotion
11
14
  module Collector
12
15
  SCHEMA_VERSION = 1
13
16
  MAX_PACKAGES = 400
@@ -15,6 +18,16 @@ module Auditea
15
18
  MAX_REQUIREMENT_BYTES = 256
16
19
  # Leave headroom under Transport::Limits::SINGLE_MAX_BYTES after envelope/provenance.
17
20
  MAX_INVENTORY_BYTES = 110_000
21
+ PROMOTED_SERVER_GEMS = {
22
+ "puma" => { "product" => "Puma", "scope" => "application_server" },
23
+ "unicorn" => { "product" => "Unicorn", "scope" => "application_server" },
24
+ "passenger" => { "product" => "Phusion Passenger", "scope" => "application_server" }
25
+ }.freeze
26
+ PROMOTED_ADAPTER_GEMS = {
27
+ "pg" => { "product" => "pg (PostgreSQL adapter)", "scope" => "linked_library" },
28
+ "trilogy" => { "product" => "Trilogy", "scope" => "linked_library" },
29
+ "mysql2" => { "product" => "mysql2", "scope" => "linked_library" }
30
+ }.freeze
18
31
 
19
32
  module_function
20
33
 
@@ -41,11 +54,124 @@ module Auditea
41
54
  release = Auditea.configuration.release
42
55
  inventory["application"] = { "release" => bound_string(release) } if present?(release)
43
56
 
57
+ components = Components.compact_list(
58
+ runtime_components(inventory) +
59
+ promoted_gem_components(packages) +
60
+ [DatabaseServer.collect]
61
+ )
62
+ inventory["components"] = components unless components.empty?
63
+
44
64
  inventory = enforce_size_bound(inventory)
45
65
  inventory["digest"] = DigestComputer.compute(inventory)
46
66
  inventory
47
67
  end
48
68
 
69
+ def runtime_components(inventory)
70
+ list = []
71
+ runtime = inventory["runtime"] || {}
72
+ list << Components.build(
73
+ product: "Ruby",
74
+ version: runtime["version"],
75
+ scope: "application_runtime",
76
+ observation_method: "ruby_constants"
77
+ )
78
+
79
+ framework = inventory["framework"]
80
+ if framework
81
+ list << Components.build(
82
+ product: "Rails",
83
+ version: framework["version"],
84
+ scope: "application_runtime",
85
+ observation_method: "rails_version"
86
+ )
87
+ end
88
+
89
+ pm = inventory["package_manager"] || {}
90
+ if present?(pm["rubygems_version"])
91
+ list << Components.build(
92
+ product: "RubyGems",
93
+ version: pm["rubygems_version"],
94
+ scope: "application_runtime",
95
+ observation_method: "rubygems_version"
96
+ )
97
+ end
98
+ if present?(pm["bundler_version"])
99
+ list << Components.build(
100
+ product: "Bundler",
101
+ version: pm["bundler_version"],
102
+ scope: "application_runtime",
103
+ observation_method: "bundler_version"
104
+ )
105
+ end
106
+
107
+ openssl = inventory["openssl"] || {}
108
+ openssl_version = first_present(
109
+ openssl["library_version"],
110
+ openssl["build_version"],
111
+ openssl["extension_version"]
112
+ )
113
+ if present?(openssl_version)
114
+ list << Components.build(
115
+ product: "OpenSSL",
116
+ version: openssl_version,
117
+ scope: "linked_library",
118
+ observation_method: "ruby_openssl_constants",
119
+ evidence: {
120
+ "extension_version" => openssl["extension_version"],
121
+ "build_version" => openssl["build_version"],
122
+ "library_version" => openssl["library_version"]
123
+ }.compact
124
+ )
125
+ end
126
+
127
+ list
128
+ end
129
+
130
+ def promoted_gem_components(packages)
131
+ by_name = Array(packages).to_h { |pkg| [pkg["name"].to_s, pkg] }
132
+ list = []
133
+
134
+ PROMOTED_SERVER_GEMS.each do |gem_name, meta|
135
+ pkg = by_name[gem_name]
136
+ next unless pkg
137
+
138
+ list << Components.build(
139
+ product: meta["product"],
140
+ version: pkg["version"],
141
+ scope: meta["scope"],
142
+ observation_method: "bundler_package",
143
+ package: {
144
+ "ecosystem" => pkg["ecosystem"],
145
+ "name" => pkg["name"],
146
+ "version" => pkg["version"]
147
+ }
148
+ )
149
+ end
150
+
151
+ PROMOTED_ADAPTER_GEMS.each do |gem_name, meta|
152
+ pkg = by_name[gem_name]
153
+ next unless pkg
154
+
155
+ list << Components.build(
156
+ product: meta["product"],
157
+ version: pkg["version"],
158
+ scope: meta["scope"],
159
+ observation_method: "bundler_package",
160
+ package: {
161
+ "ecosystem" => pkg["ecosystem"],
162
+ "name" => pkg["name"],
163
+ "version" => pkg["version"]
164
+ }
165
+ )
166
+ end
167
+
168
+ list
169
+ end
170
+
171
+ def first_present(*values)
172
+ values.find { |value| present?(value) }
173
+ end
174
+
49
175
  def runtime_metadata
50
176
  {
51
177
  "language" => "ruby",
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # Normalized platform/software component observations (inventory only — not vulnerability).
6
+ module Components
7
+ SCOPES = %w[
8
+ application_runtime
9
+ application_server
10
+ database_server
11
+ linked_library
12
+ host_os
13
+ host_component
14
+ reverse_proxy
15
+ externally_observed
16
+ ].freeze
17
+
18
+ MAX_COMPONENTS = 40
19
+ MAX_PRODUCT = 128
20
+ MAX_VERSION = 128
21
+ MAX_METHOD = 64
22
+ MAX_EVIDENCE_VALUE = 64
23
+ # Strict allowlist — never transmit raw command output, paths, or host details.
24
+ EVIDENCE_KEYS = %w[command adapter].freeze
25
+
26
+ module_function
27
+
28
+ def build(product:, version:, scope:, observation_method:, package: nil, evidence: nil)
29
+ return nil unless SCOPES.include?(scope.to_s)
30
+ return nil if blank?(product) || blank?(version)
31
+
32
+ entry = {
33
+ "product" => truncate(product, MAX_PRODUCT),
34
+ "version" => truncate(version, MAX_VERSION),
35
+ "scope" => scope.to_s,
36
+ "observation_method" => truncate(observation_method, MAX_METHOD)
37
+ }
38
+ entry["package"] = package if package.is_a?(Hash) && !package.empty?
39
+ sanitized = sanitize_evidence(evidence)
40
+ entry["evidence"] = sanitized if sanitized
41
+ entry
42
+ end
43
+
44
+ def sanitize_evidence(value)
45
+ return nil unless value.is_a?(Hash)
46
+
47
+ out = {}
48
+ value.each do |key, child|
49
+ key_s = key.to_s
50
+ next unless EVIDENCE_KEYS.include?(key_s)
51
+ next if child.nil?
52
+
53
+ case child
54
+ when String
55
+ next if child.empty?
56
+
57
+ out[key_s] = truncate(child, MAX_EVIDENCE_VALUE)
58
+ when Numeric, TrueClass, FalseClass
59
+ out[key_s] = child
60
+ else
61
+ next
62
+ end
63
+ end
64
+ out.empty? ? nil : out
65
+ end
66
+
67
+ def compact_list(list)
68
+ Array(list).compact.first(MAX_COMPONENTS)
69
+ end
70
+
71
+ def truncate(value, max)
72
+ StringBound.truncate(value.to_s, max)
73
+ end
74
+
75
+ def blank?(value)
76
+ value.nil? || value.to_s.strip.empty?
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # Fail-open database server version collectors.
6
+ # Never transmits credentials, connection strings, or DB hostnames.
7
+ module DatabaseServer
8
+ module_function
9
+
10
+ def collect
11
+ return nil unless defined?(::ActiveRecord::Base)
12
+ return nil unless ::ActiveRecord::Base.respond_to?(:connection_pool)
13
+
14
+ pool = ::ActiveRecord::Base.connection_pool
15
+ return nil unless pool.respond_to?(:with_connection)
16
+
17
+ pool.with_connection do |connection|
18
+ return nil unless connection
19
+
20
+ adapter = connection.adapter_name.to_s.downcase
21
+ collector = collector_for(adapter)
22
+ return nil unless collector
23
+
24
+ collector.call(connection)
25
+ end
26
+ rescue StandardError
27
+ nil
28
+ end
29
+
30
+ def collector_for(adapter)
31
+ case adapter
32
+ when /postgre/
33
+ method(:postgresql)
34
+ end
35
+ end
36
+
37
+ def postgresql(connection)
38
+ version = nil
39
+ version = connection.select_value("SHOW server_version") if connection.respond_to?(:select_value)
40
+ version = version.to_s.strip
41
+ return nil if version.empty?
42
+
43
+ Components.build(
44
+ product: "PostgreSQL",
45
+ version: version,
46
+ scope: "database_server",
47
+ observation_method: "active_record.show_server_version",
48
+ evidence: {
49
+ "adapter" => "postgresql"
50
+ }
51
+ )
52
+ rescue StandardError
53
+ nil
54
+ end
55
+ end
56
+ end
57
+ end
@@ -12,6 +12,7 @@ module Auditea
12
12
 
13
13
  def call(env)
14
14
  CurrentContext.clear!
15
+ Instrumentation::Exception::Deduper.clear!
15
16
  request = ::Rack::Request.new(env)
16
17
  CurrentContext.set(
17
18
  "request_id" => env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
@@ -24,6 +25,7 @@ module Auditea
24
25
  ensure
25
26
  opportunistically_refresh_inventory
26
27
  CurrentContext.clear!
28
+ Instrumentation::Exception::Deduper.clear!
27
29
  end
28
30
 
29
31
  private
@@ -18,14 +18,16 @@ module Auditea
18
18
  initializer "auditea.instrumentation" do
19
19
  Auditea::Instrumentation::Request.install!
20
20
  Auditea::Instrumentation::ActiveJob.install!
21
+ Auditea::Instrumentation::ErrorSubscriber.install!
21
22
  end
22
23
 
23
24
  config.after_initialize do
24
25
  at_exit { Auditea.shutdown }
25
26
  begin
26
- # Startup capture is explicit (force) so long-lived refresh can stay gated.
27
- Auditea.capture_runtime_inventory! if Auditea.configuration.capture_runtime_inventory?
28
- Auditea.capture_host_inventory! if Auditea.configuration.capture_host_inventory?
27
+ # Schedule via existing single-flight background refresh never block boot on
28
+ # detector processes or database inventory work.
29
+ Auditea::Inventory.schedule_refresh! if Auditea.configuration.capture_runtime_inventory?
30
+ Auditea::HostInventory.schedule_refresh! if Auditea.configuration.capture_host_inventory?
29
31
  rescue StandardError => error
30
32
  Auditea.handle_error(error)
31
33
  end
@@ -50,8 +50,18 @@ module Auditea
50
50
  path.to_s == "evidence.inventory.packages"
51
51
  end
52
52
 
53
+ def inventory_components_path?(path)
54
+ path.to_s == "evidence.inventory.components"
55
+ end
56
+
53
57
  def array_limit_for(path)
54
- inventory_packages_path?(path) ? INVENTORY_PACKAGES_ARRAY_MAX : DEFAULT_ARRAY_MAX
58
+ if inventory_packages_path?(path)
59
+ INVENTORY_PACKAGES_ARRAY_MAX
60
+ elsif inventory_components_path?(path)
61
+ 40
62
+ else
63
+ DEFAULT_ARRAY_MAX
64
+ end
55
65
  end
56
66
 
57
67
  def deep_sanitize(value, depth:, path:, redacted:)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Auditea
4
- VERSION = "0.1.0.beta.2"
4
+ VERSION = "0.1.0.beta.4"
5
5
  end