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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 587d3b57a35018f55c5e82c15a485715913e911a714971797a47fde50d788251
4
- data.tar.gz: beae506f6609e933baa4d4513e9015cc7b11c919a75ed83ba912eeb546aae0e5
3
+ metadata.gz: 8ed0a68e5bfed09c0f764146e65c9b995d11c7070224a19fc6e682367626d0d1
4
+ data.tar.gz: cfdc642f25cab8125b3c8479b9aacbbaa13613650fa12a9b8160e21d92c2881f
5
5
  SHA512:
6
- metadata.gz: 16e6ebde275b1f03f12028ca1c746fdedd9efb907706560ecae52b1fed188e90944f0e827c40e7a1cb909742d381fe37d26deb385ee66d82965c8185444ac99f
7
- data.tar.gz: e0c7097c1f146d724befdaf2df850e30a556c670a06406f459003b46a2b52fc77687b36d3fa366d3db4662e13718ddf70ea12698480555858bf51650ee9781c8
6
+ metadata.gz: 35cd56bc10b1010767e8078d5719639e12a056bc13aacd9472d1cda46d9679ddefba7519988af49faa26f7efd8ee664bdb1fe3f78eb27655b74c391a556406b6
7
+ data.tar.gz: 7911d88e0322ceba880eaeb09f0825d74079107612022aa60fbfff1e6e974a43a44b31d386ee304a5ff1d855d17ea1ca657198c4c3637eb7db6700ac2cb03dd6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0.beta.5 (unreleased)
4
+
5
+ ### SDK readiness diagnostic
6
+
7
+ - `Auditea.diagnostic` returns a machine-readable readiness snapshot (config flags, endpoint, token presence, release, transport health, queue counters, warnings) — never the token value
8
+ - `Auditea.emit_diagnostic!` emits the snapshot as a `diagnostic.sdk_ready` event (category `diagnostic`)
9
+ - Rails boot emits one automatic **boot/startup diagnostic observation** when configured, in asynchronous mode only (fail-open, non-blocking). In synchronous mode the automatic emission is skipped so boot never performs inline network retry work; explicit user-requested synchronous delivery is unchanged. This is not a heartbeat and does not prove ongoing process liveness.
10
+ - The snapshot describes transport state **before** the delivery of the diagnostic event that carries it; the first received diagnostic therefore has no prior delivery result.
11
+ - `Transport::Queue` now tracks `delivered` count and last delivery `outcome`/`error` (`Client#health` / `Client#stats`); batched delivery is aggregated truthfully (all-accepted vs partial/failed)
12
+
13
+ ## 0.1.0.beta.4
14
+
15
+ ### Platform component inventory (AUD-PLATFORM-INVENTORY01)
16
+
17
+ - Additive optional `evidence.inventory.components[]` on `runtime.inventory` and `host.inventory` (schema_version remains 1)
18
+ - Component fields: `product`, `version`, `scope`, `observation_method`, optional `package` / `evidence`
19
+ - Scopes: `application_runtime`, `application_server`, `database_server`, `linked_library`, `host_os`, `host_component`, `reverse_proxy`, `externally_observed`
20
+ - 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)
21
+ - Host: OS/kernel components; allowlisted detectors (`openssl version`, `apache2`/`httpd -v`, `nginx -v`) with timeout, bounded I/O, no shell, no sudo
22
+ - Absent reverse proxies are simply not observed (never claimed “not installed”)
23
+
24
+ ### FIX01 hardening
25
+
26
+ - Detectors resolve allowlisted basenames from fixed trusted system directories only (realpath boundary); never `ENV["PATH"]`
27
+ - Detector evidence is basename + observation metadata only — no raw stdout/stderr
28
+ - Aggregate detector wall-clock budget; hanging children are process-group killed and reaped
29
+ - Rails `after_initialize` schedules existing background inventory refresh (does not block boot)
30
+ - PostgreSQL version uses `connection_pool.with_connection` and releases the checkout
31
+
32
+ ### FIX02 executable trust
33
+
34
+ - After realpath containment, require root-owned (uid 0) directory and executable
35
+ - Reject group/world-writable directories or executables; reject app-UID-owned material
36
+ - Ordinary `/bin` → `/usr/bin` realpath equivalence remains supported; symlink escapes fail open as not observed
37
+
38
+ ### FIX03 process lifecycle
39
+
40
+ - After `Process.spawn`, every exit path terminates/reaps the child (ensure-owned cleanup; not timeout-only)
41
+ - Fail-open cleanup tolerates ESRCH / ECHILD / EINTR; no double-wait escape
42
+
3
43
  ## 0.1.0.beta.3 (unreleased)
4
44
 
5
45
  ### Exception capture parity (AUD-ERROR-PARITY01)
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.3** (not published yet)
5
+ Version: **0.1.0.beta.5** (proposed, not yet published)
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.3"
15
+ gem "auditea", "0.1.0.beta.5"
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).
@@ -155,7 +168,7 @@ AUDITEA_CAPTURE_RUNTIME_INVENTORY=true
155
168
  # optional: AUDITEA_INVENTORY_REFRESH_INTERVAL=86400
156
169
  ```
157
170
 
158
- 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.
159
172
 
160
173
  Enabling inventory reveals internal gem names to AudiTea. Keep it off unless that is intentional.
161
174
 
data/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Release: auditea 0.1.0.beta.3
1
+ # Release: auditea 0.1.0.beta.5
2
2
 
3
3
  Authoritative runbook for controlled beta publication of the Ruby SDK.
4
4
 
@@ -29,6 +29,22 @@ If AudiTea `main` advances after strict interop and before publication in a way
29
29
 
30
30
  ---
31
31
 
32
+ ## Ruby SDK setup catalog (AudiTea Source page)
33
+
34
+ Any auditea-ruby release that changes the recommended gem version, configuration
35
+ keys (ENV names), defaults, or capture capabilities **MUST** evaluate — and, when
36
+ required, update — AudiTea's Ruby SDK setup catalog
37
+ (`config/auditea_sdks/ruby.yml` in `reznis/AudiTea`) before that release is
38
+ considered fully rollout-ready.
39
+
40
+ The catalog is the single source of truth for the setup instructions rendered on
41
+ the AudiTea Source page. A release whose advertised settings diverge from the
42
+ catalog produces stale or misleading customer onboarding instructions. Updating
43
+ the recommended supported version remains an intentional release action — never a
44
+ runtime RubyGems query.
45
+
46
+ ---
47
+
32
48
  ## Two interoperability gates (do not conflate)
33
49
 
34
50
  ### Gate A — pinned AudiTea envelope compatibility
@@ -153,10 +169,10 @@ PR status may say `READY FOR HUMAN PUBLICATION REVIEW` while still listing exter
153
169
  ```bash
154
170
  git checkout main
155
171
  git pull
156
- # confirm VERSION and CHANGELOG still say 0.1.0.beta.3
172
+ # confirm VERSION and CHANGELOG still say 0.1.0.beta.5
157
173
  gem build auditea.gemspec
158
174
  # inspect package once more
159
- gem push auditea-0.1.0.beta.3.gem
175
+ gem push auditea-0.1.0.beta.5.gem
160
176
  ```
161
177
 
162
178
  Then mark CHANGELOG released with the publication date in a follow-up commit.
@@ -166,9 +182,9 @@ Then mark CHANGELOG released with the publication date in a follow-up commit.
166
182
  ## Post-publication verification
167
183
 
168
184
  ```bash
169
- gem install auditea -v 0.1.0.beta.3 --pre
185
+ gem install auditea -v 0.1.0.beta.5 --pre
170
186
  ruby -rauditea -e 'puts Auditea::VERSION'
171
- # expect: 0.1.0.beta.3
187
+ # expect: 0.1.0.beta.5
172
188
  ```
173
189
 
174
190
  Prefer installing the RubyGems artifact (not a git branch) into the controlled TEST app.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gate logic for examples/windows_inventory_smoke.rb — extracted so the exit
4
+ # behavior can be unit-tested without running a real Windows host.
5
+ module WindowsSmokeGate
6
+ module_function
7
+
8
+ Result = Data.define(:label, :ok)
9
+
10
+ # platform: the inventory "platform" hash (os_name / os_version).
11
+ # components: the inventory "components" array.
12
+ # require_edition: when set, the os_name must equal this exact edition.
13
+ # require_products: product names that MUST be present in components.
14
+ def checks(platform:, components:, require_edition: nil, require_products: [])
15
+ platform = {} unless platform.is_a?(Hash)
16
+ components = Array(components)
17
+
18
+ results = []
19
+ results << Result.new(label: "windows edition detected", ok: edition_ok?(platform["os_name"].to_s, require_edition))
20
+ results << Result.new(label: "os build present", ok: !platform["os_version"].to_s.strip.empty?)
21
+
22
+ Array(require_products).each do |product|
23
+ results << Result.new(label: "product detected: #{product}", ok: components.any? { |c|
24
+ c["product"].to_s == product.to_s
25
+ })
26
+ end
27
+
28
+ results
29
+ end
30
+
31
+ def edition_ok?(edition, require_edition)
32
+ return false if edition.empty?
33
+ return false if edition == "Windows_NT" # kernel-only: edition unknown
34
+
35
+ return edition == require_edition if require_edition && !require_edition.to_s.empty?
36
+
37
+ true
38
+ end
39
+
40
+ def failed?(results)
41
+ Array(results).any? { |result| !result.ok }
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Windows host-inventory smoke — run ON the Windows host (e.g. IRCM). No server needed.
4
+ #
5
+ # Validates the full detection path end-to-end:
6
+ # Gem.win_platform? -> windows? -> reg.exe -> parse -> components + OS edition.
7
+ #
8
+ # NEVER transmits anything to AudiTea: it calls Collector directly and prints
9
+ # only. Do not require AUDITEA_CAPTURE_HOST_INVENTORY=1 to run it.
10
+ #
11
+ # Usage (from an auditea-ruby checkout):
12
+ # bundle exec ruby examples/windows_inventory_smoke.rb
13
+ #
14
+ # Gate (exit 1 when a required expectation fails):
15
+ # REQUIRE_EDITION="Windows Server 2016 Standard" \
16
+ # REQUIRE_PRODUCTS="Apache HTTP Server,OpenSSL" \
17
+ # bundle exec ruby examples/windows_inventory_smoke.rb
18
+ #
19
+ # Expected on IRCM:
20
+ # platform.os_name == "Windows Server 2016 Standard" (NOT "Windows_NT")
21
+ # platform.os_version == "10.0.14393" (NOT "unknown")
22
+ # a component { "product" => "Apache HTTP Server", "scope" => "reverse_proxy", ... }
23
+ # a component { "product" => "OpenSSL", "scope" => "host_component", ... }
24
+
25
+ require "auditea"
26
+ require "json"
27
+ require_relative "support/windows_smoke_gate"
28
+
29
+ inventory = Auditea::HostInventory::Collector.collect
30
+
31
+ platform = inventory["platform"] || {}
32
+ components = inventory["components"] || []
33
+
34
+ puts "== platform =="
35
+ puts JSON.pretty_generate(platform)
36
+
37
+ puts "== sdk =="
38
+ puts JSON.pretty_generate(inventory["sdk"])
39
+
40
+ puts "== components (#{components.size}) =="
41
+ components.each { |component| puts JSON.pretty_generate(component) }
42
+ puts "(no components)" if components.empty?
43
+
44
+ require_edition = ENV["REQUIRE_EDITION"].to_s.strip
45
+ require_products = ENV["REQUIRE_PRODUCTS"].to_s.split(",").map(&:strip).reject(&:empty?)
46
+
47
+ results = WindowsSmokeGate.checks(
48
+ platform: platform,
49
+ components: components,
50
+ require_edition: require_edition.empty? ? nil : require_edition,
51
+ require_products: require_products
52
+ )
53
+
54
+ puts
55
+ puts "== checks =="
56
+ results.each do |result|
57
+ puts format("%-40<label>s %<verdict>s", label: result.label, verdict: (result.ok ? "PASS" : "FAIL"))
58
+ end
59
+
60
+ if WindowsSmokeGate.failed?(results)
61
+ warn "smoke gate FAILED — see checks above."
62
+ exit 1
63
+ end
64
+
65
+ puts "smoke gate passed."
@@ -25,6 +25,16 @@ module Auditea
25
25
  queue.shutdown
26
26
  end
27
27
 
28
+ # Queue depth/drop/inflight counters (see Transport::Queue#stats).
29
+ def stats
30
+ queue.stats
31
+ end
32
+
33
+ # Recent transport health: last delivery outcome + error (see Transport::Queue#health).
34
+ def health
35
+ queue.health
36
+ end
37
+
28
38
  def reset!
29
39
  shutdown
30
40
  rebuild!
@@ -20,7 +20,7 @@ module Auditea
20
20
  :open_timeout, :write_timeout, :max_retries, :logger, :release, :sdk_name,
21
21
  :capture_requests, :capture_exceptions, :capture_request_params,
22
22
  :capture_active_job, :capture_runtime_inventory, :capture_host_inventory,
23
- :inventory_refresh_interval, :host_key, :host_label,
23
+ :emit_boot_diagnostic, :inventory_refresh_interval, :host_key, :host_label,
24
24
  :message_max_bytes, :nesting_max_depth
25
25
 
26
26
  def initialize
@@ -47,6 +47,7 @@ module Auditea
47
47
  @capture_runtime_inventory = truthy?(ENV.fetch("AUDITEA_CAPTURE_RUNTIME_INVENTORY", "false"))
48
48
  # Opt-in: host/platform inventory (opaque node id + OS facts).
49
49
  @capture_host_inventory = truthy?(ENV.fetch("AUDITEA_CAPTURE_HOST_INVENTORY", "false"))
50
+ @emit_boot_diagnostic = truthy?(ENV.fetch("AUDITEA_EMIT_BOOT_DIAGNOSTIC", "true"))
50
51
  @host_key = ENV.fetch("AUDITEA_HOST_KEY", nil)
51
52
  @host_label = ENV.fetch("AUDITEA_HOST_LABEL", nil)
52
53
  @inventory_refresh_interval = float_env(
@@ -98,6 +99,12 @@ module Auditea
98
99
  host = normalize_host(uri.host)
99
100
  return false if host.empty?
100
101
 
102
+ # A base endpoint has no legitimate userinfo (credentials), query, or
103
+ # fragment. Reject these dangerous URL forms rather than silently
104
+ # supporting them — they can embed secrets in a place the generic key
105
+ # sanitizer cannot reach.
106
+ return false if uri.userinfo || uri.query || uri.fragment
107
+
101
108
  case uri
102
109
  when URI::HTTPS
103
110
  true
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ # Builds a machine-readable readiness snapshot for the SDK: what is configured,
5
+ # what is missing, and recent transport health.
6
+ #
7
+ # Never includes the source token value — only its presence. Intended both for
8
+ # direct inspection (`Auditea.diagnostic`) and for transport as event metadata
9
+ # (`Auditea.emit_diagnostic!` → `diagnostic.sdk_ready`).
10
+ class Diagnostic
11
+ def self.call
12
+ new.call
13
+ end
14
+
15
+ def call
16
+ cfg = Auditea.configuration
17
+ {
18
+ gem_version: VERSION,
19
+ configured: cfg.configured?,
20
+ endpoint: safe_endpoint(cfg.endpoint),
21
+ token_present: token_present?(cfg),
22
+ release: presence(cfg.release),
23
+ enabled: cfg.enabled?,
24
+ async: cfg.async?,
25
+ raise_errors: cfg.raise_errors,
26
+ capture_exceptions: cfg.capture_exceptions,
27
+ capture_runtime_inventory: cfg.capture_runtime_inventory?,
28
+ capture_host_inventory: cfg.capture_host_inventory?,
29
+ capture_requests: cfg.capture_requests,
30
+ capture_request_params: cfg.capture_request_params,
31
+ capture_active_job: cfg.capture_active_job,
32
+ valid: valid?(cfg),
33
+ transport: Auditea.client.health,
34
+ queue: Auditea.client.stats,
35
+ warnings: warnings(cfg)
36
+ }
37
+ end
38
+
39
+ private
40
+
41
+ def token_present?(cfg)
42
+ !cfg.source_token.to_s.strip.empty?
43
+ end
44
+
45
+ # Endpoint representation safe-by-construction: strips userinfo (credentials),
46
+ # query, and fragment so secrets embedded in the URL never enter the snapshot.
47
+ # Returns nil for an unparseable endpoint (nothing sensitive is exposed).
48
+ def safe_endpoint(value)
49
+ uri = URI.parse(value.to_s)
50
+ return nil if uri.host.to_s.empty?
51
+
52
+ uri.user = nil
53
+ uri.password = nil
54
+ uri.query = nil
55
+ uri.fragment = nil
56
+ uri.to_s
57
+ rescue URI::InvalidURIError, ArgumentError
58
+ nil
59
+ end
60
+
61
+ def valid?(cfg)
62
+ cfg.validate!
63
+ true
64
+ rescue ConfigurationError
65
+ false
66
+ end
67
+
68
+ def warnings(cfg)
69
+ [].tap do |warnings|
70
+ warnings << "source_token unset" unless token_present?(cfg)
71
+ warnings << "release unset — events will lack a release" if presence(cfg.release).nil?
72
+ if cfg.endpoint.to_s == Configuration::DEFAULT_ENDPOINT
73
+ warnings << "endpoint is the SDK default (#{Configuration::DEFAULT_ENDPOINT})"
74
+ end
75
+ warnings << "runtime inventory disabled" unless cfg.capture_runtime_inventory?
76
+ warnings << "host inventory disabled" unless cfg.capture_host_inventory?
77
+ warnings << "raise_errors enabled — SDK errors may raise in the application" if cfg.raise_errors
78
+ warnings << "SDK disabled" unless cfg.enabled?
79
+ end
80
+ end
81
+
82
+ def presence(value)
83
+ value.to_s.strip.empty? ? nil : value.to_s.strip
84
+ end
85
+ end
86
+ end
@@ -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,62 @@ 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: host_os_provenance(platform)
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
+
91
+ # The OS-edition row's provenance differs by platform: /etc/os-release on
92
+ # Linux, the Windows registry ProductName on Windows. Never claim os_release
93
+ # for a Windows host.
94
+ def host_os_provenance(platform)
95
+ platform["os_family"].to_s == "windows" ? "windows.registry" : "os_release"
96
+ end
97
+ private_class_method :host_os_provenance
98
+
46
99
  def resolve_host_key!
47
100
  configured = Auditea.configuration.host_key.to_s.strip
48
101
  configured = ENV.fetch("AUDITEA_HOST_KEY", "").to_s.strip if configured.empty?
@@ -127,6 +180,14 @@ module Auditea
127
180
  }
128
181
  end
129
182
 
183
+ if os_family == "windows"
184
+ return {
185
+ name: first_present(WindowsComponents.product_name, uname[:sysname], "Windows"),
186
+ # Windows kernel release doubles as the OS build (e.g. "10.0.14393").
187
+ version: first_present(uname[:release])
188
+ }
189
+ end
190
+
130
191
  {
131
192
  name: first_present(uname[:sysname], RbConfig::CONFIG["host_os"]),
132
193
  # Never substitute kernel build metadata (uname.version) as product OS version.
@@ -224,6 +285,7 @@ module Auditea
224
285
  # Drop optional fields first; keep identity + required platform.
225
286
  trimmed = inventory.dup
226
287
  trimmed.delete("label")
288
+ trimmed.delete("components")
227
289
  trimmed["hardware"] = {}
228
290
  trimmed["runtime_context"] = {}
229
291
  trimmed