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,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "etc"
5
+ require "rbconfig"
6
+ require "socket"
7
+ require_relative "../inventory/string_bound"
8
+ require_relative "../inventory/digest"
9
+
10
+ module Auditea
11
+ module HostInventory
12
+ # Collects a bounded host/platform inventory.
13
+ # Never transmits raw hostname, machine-id, paths, env vars, credentials, or tokens.
14
+ # Opaque host_key is either configured or a local SHA-256 of non-transmitted material.
15
+ module Collector
16
+ SCHEMA_VERSION = 1
17
+ MAX_FIELD_BYTES = 128
18
+ MAX_HOST_KEY = 128
19
+ HOST_KEY_FORMAT = /\A[A-Za-z0-9._:-]{8,128}\z/
20
+ MAX_INVENTORY_BYTES = 8_192
21
+ OS_RELEASE_PATH = "/etc/os-release"
22
+
23
+ module_function
24
+
25
+ def collect
26
+ inventory = {
27
+ "schema_version" => SCHEMA_VERSION,
28
+ "host_key" => resolve_host_key!,
29
+ "platform" => platform_metadata,
30
+ "hardware" => hardware_metadata,
31
+ "runtime_context" => runtime_context_metadata,
32
+ "sdk" => {
33
+ "name" => "auditea",
34
+ "version" => VERSION
35
+ }
36
+ }
37
+
38
+ label = configured_label
39
+ inventory["label"] = label if label
40
+
41
+ inventory = enforce_size_bound(inventory)
42
+ inventory["digest"] = Inventory::DigestComputer.compute(inventory)
43
+ inventory
44
+ end
45
+
46
+ def resolve_host_key!
47
+ configured = Auditea.configuration.host_key.to_s.strip
48
+ configured = ENV.fetch("AUDITEA_HOST_KEY", "").to_s.strip if configured.empty?
49
+ if present?(configured)
50
+ unless configured.match?(HOST_KEY_FORMAT)
51
+ raise ConfigurationError,
52
+ "AUDITEA_HOST_KEY must match opaque host key format"
53
+ end
54
+
55
+ return configured
56
+ end
57
+
58
+ Digest::SHA256.hexdigest(local_identity_material.join("\0"))
59
+ end
60
+
61
+ # Local-only inputs. Never included in the inventory payload.
62
+ # Containers sharing an image machine-id still differentiate via hostname material.
63
+ def local_identity_material
64
+ parts = []
65
+ machine = read_local_machine_id
66
+ parts << machine if present?(machine)
67
+ parts << safe_local_hostname if containerized? || !present?(machine)
68
+ parts << "container" if containerized?
69
+ parts << RUBY_PLATFORM.to_s
70
+ parts << RbConfig::CONFIG["arch"].to_s
71
+ parts << RbConfig::CONFIG["host_os"].to_s
72
+ parts
73
+ end
74
+ private_class_method :local_identity_material
75
+
76
+ def read_local_machine_id
77
+ %w[/etc/machine-id /var/lib/dbus/machine-id].each do |path|
78
+ next unless File.readable?(path)
79
+
80
+ value = File.read(path, 64).to_s.strip
81
+ return value if present?(value)
82
+ end
83
+ nil
84
+ rescue StandardError
85
+ nil
86
+ end
87
+ private_class_method :read_local_machine_id
88
+
89
+ def safe_local_hostname
90
+ Socket.gethostname.to_s
91
+ rescue StandardError
92
+ "unknown"
93
+ end
94
+ private_class_method :safe_local_hostname
95
+
96
+ def configured_label
97
+ label = Auditea.configuration.host_label.to_s.strip
98
+ label = ENV.fetch("AUDITEA_HOST_LABEL", "").to_s.strip if label.empty?
99
+ return nil unless present?(label)
100
+
101
+ bound_string(label)
102
+ end
103
+ private_class_method :configured_label
104
+
105
+ def platform_metadata
106
+ uname = safe_uname
107
+ arch = first_present(uname[:machine], RbConfig::CONFIG["arch"], RUBY_PLATFORM)
108
+ os = os_identity(uname)
109
+ meta = {
110
+ "os_family" => bound_string(os_family),
111
+ "os_name" => bound_string(os[:name]),
112
+ "architecture" => bound_string(arch),
113
+ "kernel_name" => bound_string(uname[:sysname]),
114
+ "kernel_release" => bound_string(uname[:release])
115
+ }
116
+ meta["os_version"] = bound_string(os[:version]) if present?(os[:version])
117
+ meta.compact
118
+ end
119
+ private_class_method :platform_metadata
120
+
121
+ def os_identity(uname)
122
+ if os_family == "linux"
123
+ release = parse_os_release
124
+ return {
125
+ name: first_present(release["NAME"], release["ID"], "Linux"),
126
+ version: first_present(release["VERSION_ID"], release["VERSION"])
127
+ }
128
+ end
129
+
130
+ {
131
+ name: first_present(uname[:sysname], RbConfig::CONFIG["host_os"]),
132
+ # Never substitute kernel build metadata (uname.version) as product OS version.
133
+ version: nil
134
+ }
135
+ end
136
+ private_class_method :os_identity
137
+
138
+ def parse_os_release(path: OS_RELEASE_PATH)
139
+ return {} unless File.readable?(path)
140
+
141
+ File.foreach(path).with_object({}) do |line, memo|
142
+ line = line.strip
143
+ next if line.empty? || line.start_with?("#")
144
+
145
+ key, value = line.split("=", 2)
146
+ next if key.nil? || value.nil?
147
+
148
+ memo[key] = value.delete_prefix('"').delete_suffix('"').delete_prefix("'").delete_suffix("'")
149
+ end
150
+ rescue StandardError
151
+ {}
152
+ end
153
+ private_class_method :parse_os_release
154
+
155
+ def first_present(*values)
156
+ values.find { |value| present?(value) }
157
+ end
158
+ private_class_method :first_present
159
+
160
+ def hardware_metadata
161
+ count = safe_logical_cpu_count
162
+ return {} unless count
163
+
164
+ { "logical_cpu_count" => count }
165
+ end
166
+ private_class_method :hardware_metadata
167
+
168
+ def runtime_context_metadata
169
+ { "container" => containerized? }
170
+ end
171
+ private_class_method :runtime_context_metadata
172
+
173
+ def safe_uname
174
+ return {} unless Etc.respond_to?(:uname)
175
+
176
+ raw = Etc.uname
177
+ return {} unless raw.is_a?(Hash)
178
+
179
+ {
180
+ sysname: raw[:sysname] || raw["sysname"],
181
+ release: raw[:release] || raw["release"],
182
+ version: raw[:version] || raw["version"],
183
+ machine: raw[:machine] || raw["machine"]
184
+ }
185
+ rescue StandardError
186
+ {}
187
+ end
188
+ private_class_method :safe_uname
189
+
190
+ def os_family
191
+ host_os = RbConfig::CONFIG["host_os"].to_s.downcase
192
+ case host_os
193
+ when /linux/ then "linux"
194
+ when /darwin|mac os/ then "macos"
195
+ when /mswin|mingw|cygwin/ then "windows"
196
+ when /bsd/ then "bsd"
197
+ else "other"
198
+ end
199
+ end
200
+ private_class_method :os_family
201
+
202
+ def safe_logical_cpu_count
203
+ return Etc.nprocessors if Etc.respond_to?(:nprocessors)
204
+
205
+ nil
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ private_class_method :safe_logical_cpu_count
210
+
211
+ def containerized?
212
+ return true if File.exist?("/.dockerenv")
213
+
214
+ false
215
+ rescue StandardError
216
+ false
217
+ end
218
+ private_class_method :containerized?
219
+
220
+ def enforce_size_bound(inventory)
221
+ json = JSON.generate(inventory)
222
+ return inventory if json.bytesize <= MAX_INVENTORY_BYTES
223
+
224
+ # Drop optional fields first; keep identity + required platform.
225
+ trimmed = inventory.dup
226
+ trimmed.delete("label")
227
+ trimmed["hardware"] = {}
228
+ trimmed["runtime_context"] = {}
229
+ trimmed
230
+ end
231
+ private_class_method :enforce_size_bound
232
+
233
+ def bound_string(value)
234
+ return nil unless present?(value)
235
+
236
+ Inventory::StringBound.truncate(value.to_s, MAX_FIELD_BYTES)
237
+ end
238
+ private_class_method :bound_string
239
+
240
+ def present?(value)
241
+ !value.nil? && value.to_s.strip != ""
242
+ end
243
+ private_class_method :present?
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module HostInventory
5
+ # Process-local emission gate: ~daily refresh, immediate emit on digest change.
6
+ # Independent of Runtime Inventory emitter state; shares refresh interval config.
7
+ module Emitter
8
+ ACTION = "host.inventory"
9
+ CATEGORY = "security"
10
+
11
+ module_function
12
+
13
+ def capture!(force: true)
14
+ return skipped("disabled") unless Auditea.configuration.capture_host_inventory?
15
+
16
+ return skipped("not_due") if !force && !collection_due?
17
+
18
+ mark_collection_attempt!
19
+ inventory = Collector.collect
20
+ emit(inventory, force: force)
21
+ rescue StandardError => error
22
+ Auditea.handle_error(error)
23
+ end
24
+
25
+ def maybe_capture!
26
+ capture!(force: false)
27
+ end
28
+
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 ? "host_inventory_explicit" : "host_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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inventory/string_bound"
4
+ require_relative "inventory/digest"
5
+ require_relative "host_inventory/collector"
6
+ require_relative "host_inventory/emitter"
7
+ require_relative "host_inventory/background_refresh"
8
+
9
+ module Auditea
10
+ # Opt-in host/platform inventory (`host.inventory`). Default off.
11
+ # Distinct from runtime/dependency inventory: reports opaque node identity + OS facts.
12
+ module HostInventory
13
+ module_function
14
+
15
+ def capture!(force: true)
16
+ Emitter.capture!(force: force)
17
+ end
18
+
19
+ def maybe_capture!
20
+ Emitter.maybe_capture!
21
+ end
22
+
23
+ # Cheap request-path entry: schedule background capture when due (never inline).
24
+ def schedule_refresh!
25
+ BackgroundRefresh.schedule!
26
+ end
27
+
28
+ def collection_due?
29
+ Emitter.collection_due?
30
+ end
31
+
32
+ def reset!
33
+ Emitter.reset!
34
+ BackgroundRefresh.reset!
35
+ end
36
+ end
37
+ 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