auditea 0.1.0.beta.1 → 0.1.0.beta.2

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.
@@ -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
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Auditea
7
+ module Inventory
8
+ # Stable digest over normalized inventory (no timestamps / PIDs / hostnames).
9
+ module DigestComputer
10
+ module_function
11
+
12
+ def compute(inventory)
13
+ payload = canonicalize(inventory_without_digest(inventory))
14
+ Digest::SHA256.hexdigest(JSON.generate(payload))
15
+ end
16
+
17
+ def inventory_without_digest(inventory)
18
+ case inventory
19
+ when Hash
20
+ inventory.each_with_object({}) do |(key, value), memo|
21
+ next if key.to_s == "digest"
22
+
23
+ memo[key.to_s] = inventory_without_digest(value)
24
+ end
25
+ when Array
26
+ inventory.map { |value| inventory_without_digest(value) }
27
+ else
28
+ inventory
29
+ end
30
+ end
31
+
32
+ def canonicalize(value)
33
+ case value
34
+ when Hash
35
+ value.keys.map(&:to_s).sort.to_h do |key|
36
+ [key, canonicalize(hash_fetch(value, key))]
37
+ end
38
+ when Array
39
+ value.map { |child| canonicalize(child) }
40
+ when String, Numeric, TrueClass, FalseClass, NilClass
41
+ value
42
+ else
43
+ value.to_s
44
+ end
45
+ end
46
+
47
+ # Prefer key existence over truthiness so false/nil are preserved.
48
+ def hash_fetch(hash, key)
49
+ return hash[key] if hash.key?(key)
50
+
51
+ sym = key.to_sym
52
+ return hash[sym] if hash.key?(sym)
53
+
54
+ nil
55
+ end
56
+ private_class_method :hash_fetch
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # Process-local emission gate: ~daily refresh, immediate emit on digest change.
6
+ module Emitter
7
+ ACTION = "runtime.inventory"
8
+ CATEGORY = "security"
9
+
10
+ module_function
11
+
12
+ def capture!(force: true)
13
+ return skipped("disabled") unless Auditea.configuration.capture_runtime_inventory?
14
+
15
+ return skipped("not_due") if !force && !collection_due?
16
+
17
+ mark_collection_attempt!
18
+ inventory = Collector.collect
19
+ emit(inventory, force: force)
20
+ rescue StandardError => error
21
+ Auditea.handle_error(error)
22
+ end
23
+
24
+ def maybe_capture!
25
+ capture!(force: false)
26
+ end
27
+
28
+ # Cheap gate for request/lifecycle hooks — does not collect until due.
29
+ def collection_due?(now: monotonic_now)
30
+ state_mutex.synchronize do
31
+ return true if @last_collection_attempt_at.nil?
32
+
33
+ (now - @last_collection_attempt_at) >= Auditea.configuration.inventory_refresh_interval
34
+ end
35
+ end
36
+
37
+ def reset!
38
+ state_mutex.synchronize do
39
+ @last_digest = nil
40
+ @last_emitted_at = nil
41
+ @last_collection_attempt_at = nil
42
+ end
43
+ end
44
+
45
+ def emit(inventory, force:)
46
+ digest = inventory["digest"].to_s
47
+ unless force || should_emit?(digest)
48
+ return { ok: true, skipped: true, reason: "unchanged_within_interval", digest: digest }
49
+ end
50
+
51
+ result = Auditea.capture(
52
+ ACTION,
53
+ category: CATEGORY,
54
+ evidence: { "inventory" => inventory },
55
+ metadata: { "sdk_event_kind" => force ? "runtime_inventory_explicit" : "runtime_inventory_auto" }
56
+ )
57
+
58
+ mark_emitted!(digest) if result.is_a?(Hash) && (result[:ok] || result["ok"])
59
+ result.is_a?(Hash) ? result.merge(digest: digest) : result
60
+ end
61
+ private_class_method :emit
62
+
63
+ def should_emit?(digest)
64
+ state_mutex.synchronize do
65
+ return true if @last_digest.nil?
66
+ return true if digest != @last_digest
67
+ return true if @last_emitted_at.nil?
68
+
69
+ (monotonic_now - @last_emitted_at) >= Auditea.configuration.inventory_refresh_interval
70
+ end
71
+ end
72
+ private_class_method :should_emit?
73
+
74
+ def mark_emitted!(digest)
75
+ state_mutex.synchronize do
76
+ @last_digest = digest
77
+ @last_emitted_at = monotonic_now
78
+ end
79
+ end
80
+ private_class_method :mark_emitted!
81
+
82
+ def mark_collection_attempt!
83
+ state_mutex.synchronize do
84
+ @last_collection_attempt_at = monotonic_now
85
+ end
86
+ end
87
+ private_class_method :mark_collection_attempt!
88
+
89
+ def skipped(reason)
90
+ { ok: false, skipped: true, reason: reason }
91
+ end
92
+ private_class_method :skipped
93
+
94
+ def state_mutex
95
+ @state_mutex ||= Mutex.new
96
+ end
97
+ private_class_method :state_mutex
98
+
99
+ def monotonic_now
100
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ end
102
+ private_class_method :monotonic_now
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # UTF-8-safe byte bounds for inventory string fields.
6
+ module StringBound
7
+ ELLIPSIS = "…"
8
+ ELLIPSIS_BYTES = ELLIPSIS.bytesize
9
+
10
+ module_function
11
+
12
+ # Returns a string with bytesize <= max_bytes (valid UTF-8), or nil for nil input.
13
+ # When truncating, reserves ELLIPSIS_BYTES from the budget when max_bytes allows;
14
+ # otherwise truncates without an ellipsis. max_bytes <= 0 yields "".
15
+ def truncate(value, max_bytes)
16
+ return nil if value.nil?
17
+ return "" if max_bytes <= 0
18
+
19
+ string = value.to_s
20
+ return string if string.bytesize <= max_bytes
21
+
22
+ if max_bytes < ELLIPSIS_BYTES
23
+ truncate_bytes(string, max_bytes)
24
+ else
25
+ "#{truncate_bytes(string, max_bytes - ELLIPSIS_BYTES)}#{ELLIPSIS}"
26
+ end
27
+ end
28
+
29
+ def truncate_bytes(string, budget)
30
+ return "" if budget <= 0
31
+
32
+ truncated = string.b.byteslice(0, budget)
33
+ truncated.force_encoding(Encoding::UTF_8)
34
+ until truncated.empty? || truncated.valid_encoding?
35
+ truncated = truncated.byteslice(0, truncated.bytesize - 1)
36
+ truncated.force_encoding(Encoding::UTF_8)
37
+ end
38
+ truncated
39
+ end
40
+ private_class_method :truncate_bytes
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inventory/string_bound"
4
+ require_relative "inventory/digest"
5
+ require_relative "inventory/collector"
6
+ require_relative "inventory/emitter"
7
+ require_relative "inventory/background_refresh"
8
+
9
+ module Auditea
10
+ # Runtime and dependency inventory for AudiTea (opt-in).
11
+ module Inventory
12
+ module_function
13
+
14
+ def capture!(force: true)
15
+ Emitter.capture!(force: force)
16
+ end
17
+
18
+ def maybe_capture!
19
+ Emitter.maybe_capture!
20
+ end
21
+
22
+ # Cheap request-path entry: schedule background capture when due (never inline).
23
+ def schedule_refresh!
24
+ BackgroundRefresh.schedule!
25
+ end
26
+
27
+ def collection_due?
28
+ Emitter.collection_due?
29
+ end
30
+
31
+ def reset!
32
+ Emitter.reset!
33
+ BackgroundRefresh.reset!
34
+ end
35
+ end
36
+ end
@@ -4,6 +4,7 @@ module Auditea
4
4
  module Middleware
5
5
  # Rack middleware: sets CurrentContext with safe structural request fields.
6
6
  # Does NOT store session_id, cookies, Authorization, or query strings.
7
+ # Opportunistic inventory refresh uses a cheap due-gate only; collection is rare.
7
8
  class Context
8
9
  def initialize(app)
9
10
  @app = app
@@ -21,6 +22,7 @@ module Auditea
21
22
  )
22
23
  @app.call(env)
23
24
  ensure
25
+ opportunistically_refresh_inventory
24
26
  CurrentContext.clear!
25
27
  end
26
28
 
@@ -30,6 +32,29 @@ module Auditea
30
32
  path = request.path.to_s
31
33
  path.split("?", 2).first
32
34
  end
35
+
36
+ def opportunistically_refresh_inventory
37
+ refresh_runtime_inventory
38
+ refresh_host_inventory
39
+ rescue StandardError => error
40
+ Auditea.handle_error(error)
41
+ end
42
+
43
+ def refresh_runtime_inventory
44
+ return unless Auditea.configuration.capture_runtime_inventory?
45
+ return unless Auditea::Inventory.collection_due?
46
+
47
+ # Never collect/deliver inline on the request path (even with sync transport).
48
+ Auditea::Inventory.schedule_refresh!
49
+ end
50
+
51
+ def refresh_host_inventory
52
+ return unless Auditea.configuration.capture_host_inventory?
53
+ return unless Auditea::HostInventory.collection_due?
54
+
55
+ # Never collect/deliver inline on the request path (even with sync transport).
56
+ Auditea::HostInventory.schedule_refresh!
57
+ end
33
58
  end
34
59
  end
35
60
  end
@@ -22,6 +22,13 @@ module Auditea
22
22
 
23
23
  config.after_initialize do
24
24
  at_exit { Auditea.shutdown }
25
+ 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?
29
+ rescue StandardError => error
30
+ Auditea.handle_error(error)
31
+ end
25
32
  end
26
33
  end
27
34
  end
@@ -6,6 +6,10 @@ module Auditea
6
6
  # Exact normalized key matching only (no substring matches).
7
7
  module Sanitizer
8
8
  FILTERED = "[FILTERED]"
9
+ DEFAULT_ARRAY_MAX = 100
10
+ # Inventory packages are intentionally larger than ordinary evidence arrays.
11
+ # Keep in sync with Inventory::Collector::MAX_PACKAGES (literal avoids load-order coupling).
12
+ INVENTORY_PACKAGES_ARRAY_MAX = 400
9
13
  SENSITIVE_KEYS = %w[
10
14
  password password_confirmation password_digest authorization cookie set_cookie
11
15
  api_key access_token refresh_token secret secret_key client_secret private_key token
@@ -42,6 +46,14 @@ module Auditea
42
46
  "#{string.byteslice(0, max_bytes)}…"
43
47
  end
44
48
 
49
+ def inventory_packages_path?(path)
50
+ path.to_s == "evidence.inventory.packages"
51
+ end
52
+
53
+ def array_limit_for(path)
54
+ inventory_packages_path?(path) ? INVENTORY_PACKAGES_ARRAY_MAX : DEFAULT_ARRAY_MAX
55
+ end
56
+
45
57
  def deep_sanitize(value, depth:, path:, redacted:)
46
58
  cfg = Auditea.configuration
47
59
  return { "truncated" => true, "reason" => "max_depth" } if depth > cfg.nesting_max_depth
@@ -59,7 +71,8 @@ module Auditea
59
71
  end
60
72
  end
61
73
  when Array
62
- value.first(100).each_with_index.map do |child, index|
74
+ limit = array_limit_for(path)
75
+ value.first(limit).each_with_index.map do |child, index|
63
76
  child_path = path ? "#{path}[#{index}]" : "[#{index}]"
64
77
  deep_sanitize(child, depth: depth + 1, path: child_path, redacted: redacted)
65
78
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Auditea
4
- VERSION = "0.1.0.beta.1"
4
+ VERSION = "0.1.0.beta.2"
5
5
  end
data/lib/auditea.rb CHANGED
@@ -28,6 +28,8 @@ require_relative "auditea/transport/http"
28
28
  require_relative "auditea/transport/queue"
29
29
  require_relative "auditea/client"
30
30
  require_relative "auditea/log"
31
+ require_relative "auditea/inventory"
32
+ require_relative "auditea/host_inventory"
31
33
  require_relative "auditea/instrumentation/request"
32
34
  require_relative "auditea/instrumentation/exception"
33
35
  require_relative "auditea/instrumentation/logger_adapter"
@@ -54,6 +56,8 @@ module Auditea
54
56
  @configuration = Configuration.new
55
57
  @client&.shutdown
56
58
  @client = nil
59
+ Inventory.reset!
60
+ HostInventory.reset!
57
61
  end
58
62
 
59
63
  def client
@@ -97,6 +101,26 @@ module Auditea
97
101
  handle_error(error)
98
102
  end
99
103
 
104
+ # Opt-in runtime + dependency inventory (`runtime.inventory`). Default off.
105
+ # Does not raise on collection failure unless raise_errors is enabled.
106
+ def capture_runtime_inventory!(force: true)
107
+ Inventory.capture!(force: force)
108
+ end
109
+
110
+ # Bounded refresh for long-lived processes (~daily when unchanged).
111
+ def maybe_capture_runtime_inventory!
112
+ Inventory.maybe_capture!
113
+ end
114
+
115
+ # Opt-in host/platform inventory (`host.inventory`). Default off.
116
+ def capture_host_inventory!(force: true)
117
+ HostInventory.capture!(force: force)
118
+ end
119
+
120
+ def maybe_capture_host_inventory!
121
+ HostInventory.maybe_capture!
122
+ end
123
+
100
124
  def flush(timeout: 5)
101
125
  client.flush(timeout: timeout)
102
126
  rescue StandardError => error