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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/DESIGN.md +13 -0
- data/README.md +128 -16
- data/RELEASE.md +184 -22
- data/auditea.gemspec +2 -2
- data/lib/auditea/configuration.rb +22 -1
- data/lib/auditea/host_inventory/background_refresh.rb +64 -0
- data/lib/auditea/host_inventory/collector.rb +246 -0
- data/lib/auditea/host_inventory/emitter.rb +105 -0
- data/lib/auditea/host_inventory.rb +37 -0
- data/lib/auditea/inventory/background_refresh.rb +64 -0
- data/lib/auditea/inventory/collector.rb +301 -0
- data/lib/auditea/inventory/digest.rb +59 -0
- data/lib/auditea/inventory/emitter.rb +105 -0
- data/lib/auditea/inventory/string_bound.rb +43 -0
- data/lib/auditea/inventory.rb +36 -0
- data/lib/auditea/middleware/context.rb +25 -0
- data/lib/auditea/railtie.rb +7 -0
- data/lib/auditea/sanitizer.rb +14 -1
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +24 -0
- metadata +13 -3
|
@@ -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
|