auditea 0.1.0.beta.3 → 0.1.0.beta.5

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.
@@ -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
@@ -24,12 +24,40 @@ module Auditea
24
24
  config.after_initialize do
25
25
  at_exit { Auditea.shutdown }
26
26
  begin
27
- # Startup capture is explicit (force) so long-lived refresh can stay gated.
28
- Auditea.capture_runtime_inventory! if Auditea.configuration.capture_runtime_inventory?
29
- 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?
30
31
  rescue StandardError => error
31
32
  Auditea.handle_error(error)
32
33
  end
34
+
35
+ begin
36
+ # One automatic boot/startup diagnostic observation when configured.
37
+ # Never crash boot: fully fail-open (swallow, log, continue) regardless of
38
+ # raise_errors.
39
+ Auditea::Railtie.emit_boot_diagnostic!
40
+ rescue StandardError => error
41
+ Rails.logger&.warn("[auditea] boot diagnostic skipped: #{error.class}: #{error.message.to_s.byteslice(0, 200)}")
42
+ end
43
+ end
44
+
45
+ # Emits a single automatic boot/startup diagnostic observation when the SDK is
46
+ # configured and the flag is enabled.
47
+ #
48
+ # Emitted only in asynchronous mode. In synchronous mode the automatic
49
+ # observation is skipped entirely: enqueue would perform inline HTTP (including
50
+ # retries/timeouts), adding an external-network dependency to Rails startup.
51
+ # This does NOT weaken explicit user-requested synchronous delivery
52
+ # (Auditea.emit_diagnostic! / capture with async:false still deliver inline);
53
+ # it only gates the automatic boot path. The local snapshot
54
+ # (Auditea.diagnostic) remains available regardless.
55
+ def self.emit_boot_diagnostic!
56
+ cfg = Auditea.configuration
57
+ return unless cfg.configured? && cfg.emit_boot_diagnostic
58
+ return unless cfg.async?
59
+
60
+ Auditea.emit_diagnostic!(flush: false)
33
61
  end
34
62
  end
35
63
  end
@@ -16,8 +16,37 @@ module Auditea
16
16
  otp otp_secret session_id credit_card card_number cvv ssn
17
17
  ].freeze
18
18
 
19
+ # URI credential forms are stripped from diagnostic value strings (endpoint,
20
+ # transport errors, warnings). This is a bounded field-specific redaction for
21
+ # the diagnostic contract, NOT a general secret scanner.
22
+ URI_USERINFO_RE = %r{(https?://)[^/\s@]+@}i
23
+ SENSITIVE_QUERY_RE = /
24
+ ([?&]
25
+ (?:token|password|passwd|secret|api_?key|access_token|refresh_token|
26
+ auth|authorization|key|signature|sig|credential)
27
+ =
28
+ )
29
+ [^&\s]*
30
+ /ix
31
+
19
32
  module_function
20
33
 
34
+ # Scrub URI userinfo (`scheme://user:pass@host`) and sensitive query values
35
+ # from a free-form diagnostic string, then bound its length.
36
+ def sanitize_diagnostic_string(value, max_bytes:)
37
+ return nil if value.nil?
38
+
39
+ scrub_uri_credentials(value.to_s).byteslice(0, max_bytes)
40
+ end
41
+
42
+ # Remove `user:password@` from any embedded http(s) URL and redact the value
43
+ # of sensitive query parameters. Field-specific: only URI credential shapes.
44
+ def scrub_uri_credentials(text)
45
+ text.to_s
46
+ .gsub(URI_USERINFO_RE) { Regexp.last_match(1) }
47
+ .gsub(SENSITIVE_QUERY_RE) { "#{Regexp.last_match(1)}[REDACTED]" }
48
+ end
49
+
21
50
  def sanitize_event(event)
22
51
  redacted = []
23
52
  sanitized = deep_sanitize(event, depth: 0, path: nil, redacted: redacted)
@@ -50,8 +79,18 @@ module Auditea
50
79
  path.to_s == "evidence.inventory.packages"
51
80
  end
52
81
 
82
+ def inventory_components_path?(path)
83
+ path.to_s == "evidence.inventory.components"
84
+ end
85
+
53
86
  def array_limit_for(path)
54
- inventory_packages_path?(path) ? INVENTORY_PACKAGES_ARRAY_MAX : DEFAULT_ARRAY_MAX
87
+ if inventory_packages_path?(path)
88
+ INVENTORY_PACKAGES_ARRAY_MAX
89
+ elsif inventory_components_path?(path)
90
+ 40
91
+ else
92
+ DEFAULT_ARRAY_MAX
93
+ end
55
94
  end
56
95
 
57
96
  def deep_sanitize(value, depth:, path:, redacted:)
@@ -18,7 +18,10 @@ module Auditea
18
18
  @buffer = []
19
19
  @stopped = false
20
20
  @dropped = 0
21
+ @delivered = 0
21
22
  @inflight = 0
23
+ @last_outcome = nil
24
+ @last_error = nil
22
25
  @thread = nil
23
26
  @owner_pid = nil
24
27
  end
@@ -95,7 +98,22 @@ module Auditea
95
98
 
96
99
  def stats
97
100
  ensure_process!
98
- @mutex.synchronize { { size: @buffer.size, dropped: @dropped, inflight: @inflight, pid: @owner_pid } }
101
+ @mutex.synchronize do
102
+ {
103
+ size: @buffer.size,
104
+ dropped: @dropped,
105
+ delivered: @delivered,
106
+ inflight: @inflight,
107
+ pid: @owner_pid
108
+ }
109
+ end
110
+ end
111
+
112
+ # Recent transport health for diagnostics: what happened to the last delivery
113
+ # attempt (accepted / unauthorized / exhausted / error / …) and, on failure, why.
114
+ def health
115
+ ensure_process!
116
+ @mutex.synchronize { { last_outcome: @last_outcome, last_error: @last_error } }
99
117
  end
100
118
 
101
119
  private
@@ -106,10 +124,15 @@ module Auditea
106
124
  if @owner_pid.nil?
107
125
  @owner_pid = pid
108
126
  elsif @owner_pid != pid
109
- # Child after fork: drop inherited buffer/worker; do not deliver parent data.
127
+ # Child after fork: drop inherited buffer/worker; do not deliver parent
128
+ # data. Also reset the diagnostic transport counters/state so the child
129
+ # describes its own process, not the one it forked from.
110
130
  @buffer = []
111
131
  @inflight = 0
112
132
  @dropped = 0
133
+ @delivered = 0
134
+ @last_outcome = nil
135
+ @last_error = nil
113
136
  @stopped = false
114
137
  @thread = nil
115
138
  @owner_pid = pid
@@ -183,9 +206,11 @@ module Auditea
183
206
  else
184
207
  @http.post_batch(events)
185
208
  end
209
+ record_delivery(result)
186
210
  handle_result(events, result)
187
211
  result
188
212
  rescue StandardError => error
213
+ record_delivery_error(error)
189
214
  @configuration.logger&.warn("[auditea] delivery failed: #{error.class}: #{error.message.to_s.byteslice(0,
190
215
  200)}")
191
216
  raise if @configuration.raise_errors
@@ -220,6 +245,57 @@ module Auditea
220
245
  nil
221
246
  end
222
247
 
248
+ def record_delivery(result)
249
+ delivered, outcome, error = aggregate_delivery(result)
250
+ @mutex.synchronize do
251
+ @last_outcome = outcome
252
+ @last_error = error
253
+ @delivered += delivered
254
+ end
255
+ end
256
+
257
+ def record_delivery_error(error)
258
+ @mutex.synchronize do
259
+ @last_outcome = "error"
260
+ @last_error = Sanitizer.sanitize_diagnostic_string("#{error.class}: #{error.message}", max_bytes: 200)
261
+ end
262
+ end
263
+
264
+ # Truthful aggregate outcome for a single-event or batch DeliveryResult.
265
+ # `delivered` counts only events the server accepted/treated as success.
266
+ def aggregate_delivery(result)
267
+ return [0, "accepted", nil] unless result.is_a?(DeliveryResult)
268
+
269
+ if result.results
270
+ rows = result.results
271
+ delivered = rows.count { |row| row["ok"] }
272
+ [delivered, aggregate_batch_outcome(rows), aggregate_batch_error(rows)]
273
+ else
274
+ delivered = result.ok? ? 1 : 0
275
+ error = result.ok? ? nil : Sanitizer.sanitize_diagnostic_string(result.message, max_bytes: 200)
276
+ [delivered, result.outcome.to_s, error]
277
+ end
278
+ end
279
+
280
+ def aggregate_batch_outcome(rows)
281
+ return "error" if rows.empty?
282
+
283
+ ok = rows.count { |row| row["ok"] }
284
+ return "accepted" if ok == rows.size
285
+
286
+ bad_outcomes = rows.reject { |row| row["ok"] }.map { |row| row["outcome"] }.compact.uniq
287
+ return "partial_failure" if ok.positive?
288
+
289
+ # Total failure: surface the single uniform outcome, else a generic error.
290
+ bad_outcomes.size == 1 ? bad_outcomes.first : "error"
291
+ end
292
+
293
+ def aggregate_batch_error(rows)
294
+ return nil if rows.all? { |row| row["ok"] }
295
+
296
+ Sanitizer.sanitize_diagnostic_string("mixed_batch_results", max_bytes: 200)
297
+ end
298
+
223
299
  def log_drop(event_id, reason)
224
300
  @configuration.logger&.warn("[auditea] event_id=#{event_id} reason=#{reason}")
225
301
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Auditea
4
- VERSION = "0.1.0.beta.3"
4
+ VERSION = "0.1.0.beta.5"
5
5
  end
data/lib/auditea.rb CHANGED
@@ -22,6 +22,7 @@ require_relative "auditea/event_builder"
22
22
  require_relative "auditea/actor"
23
23
  require_relative "auditea/target"
24
24
  require_relative "auditea/current_context"
25
+ require_relative "auditea/diagnostic"
25
26
  require_relative "auditea/transport/limits"
26
27
  require_relative "auditea/transport/delivery_result"
27
28
  require_relative "auditea/transport/http"
@@ -142,6 +143,21 @@ module Auditea
142
143
  HostInventory.maybe_capture!
143
144
  end
144
145
 
146
+ # Machine-readable readiness snapshot (config + transport health + warnings).
147
+ # Never includes the source token value.
148
+ def diagnostic
149
+ Diagnostic.call
150
+ end
151
+
152
+ # Emit the readiness snapshot as a `diagnostic.sdk_ready` event, then flush.
153
+ # Returns the capture result (fail-open: { ok: false, skipped: true, reason: … }
154
+ # when disabled or not configured).
155
+ def emit_diagnostic!(flush: true)
156
+ result = capture("diagnostic.sdk_ready", category: "diagnostic", severity: "info", metadata: Diagnostic.call)
157
+ flush(timeout: 10) if flush
158
+ result
159
+ end
160
+
145
161
  def flush(timeout: 5)
146
162
  client.flush(timeout: timeout)
147
163
  rescue StandardError => error
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: auditea
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.beta.3
4
+ version: 0.1.0.beta.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - AudiTea
@@ -24,16 +24,21 @@ files:
24
24
  - RELEASE.md
25
25
  - auditea.gemspec
26
26
  - examples/manual_capture.rb
27
+ - examples/support/windows_smoke_gate.rb
28
+ - examples/windows_inventory_smoke.rb
27
29
  - lib/auditea.rb
28
30
  - lib/auditea/actor.rb
29
31
  - lib/auditea/client.rb
30
32
  - lib/auditea/configuration.rb
31
33
  - lib/auditea/current_context.rb
34
+ - lib/auditea/diagnostic.rb
32
35
  - lib/auditea/event_builder.rb
33
36
  - lib/auditea/host_inventory.rb
34
37
  - lib/auditea/host_inventory/background_refresh.rb
35
38
  - lib/auditea/host_inventory/collector.rb
39
+ - lib/auditea/host_inventory/component_detectors.rb
36
40
  - lib/auditea/host_inventory/emitter.rb
41
+ - lib/auditea/host_inventory/windows_components.rb
37
42
  - lib/auditea/instrumentation/active_job.rb
38
43
  - lib/auditea/instrumentation/error_subscriber.rb
39
44
  - lib/auditea/instrumentation/exception.rb
@@ -43,6 +48,8 @@ files:
43
48
  - lib/auditea/inventory.rb
44
49
  - lib/auditea/inventory/background_refresh.rb
45
50
  - lib/auditea/inventory/collector.rb
51
+ - lib/auditea/inventory/components.rb
52
+ - lib/auditea/inventory/database_server.rb
46
53
  - lib/auditea/inventory/digest.rb
47
54
  - lib/auditea/inventory/emitter.rb
48
55
  - lib/auditea/inventory/string_bound.rb