auditea 0.1.0.beta.3 → 0.1.0.beta.4

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: 5459c8f22551f101e1aa2f32689bf47623c3ec2890f448ff5dd92c4980ee8c91
4
+ data.tar.gz: 2fb620c50bc457db6f85df2d1745b1c761e3141571a14451caef9080db109e7c
5
5
  SHA512:
6
- metadata.gz: 16e6ebde275b1f03f12028ca1c746fdedd9efb907706560ecae52b1fed188e90944f0e827c40e7a1cb909742d381fe37d26deb385ee66d82965c8185444ac99f
7
- data.tar.gz: e0c7097c1f146d724befdaf2df850e30a556c670a06406f459003b46a2b52fc77687b36d3fa366d3db4662e13718ddf70ea12698480555858bf51650ee9781c8
6
+ metadata.gz: faf9f0876938d536fe4aad106bba8b13ee72031ecb78c14d259a9a313eb046c84601b2b0ec3af9cb510d0b9581c7eb142aea169170f364e813b79ef7ae65f32d
7
+ data.tar.gz: df66f3cab946bb98383c903d550f4f7cf842386e4fea0b2fb11b6d037b4c0a2beee1d652c3381199e9c35a30c743a17039844b68c78494429fc64d04448fbc96
data/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.0.beta.4 (unreleased)
4
+
5
+ ### Platform component inventory (AUD-PLATFORM-INVENTORY01)
6
+
7
+ - Additive optional `evidence.inventory.components[]` on `runtime.inventory` and `host.inventory` (schema_version remains 1)
8
+ - Component fields: `product`, `version`, `scope`, `observation_method`, optional `package` / `evidence`
9
+ - Scopes: `application_runtime`, `application_server`, `database_server`, `linked_library`, `host_os`, `host_component`, `reverse_proxy`, `externally_observed`
10
+ - 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)
11
+ - Host: OS/kernel components; allowlisted detectors (`openssl version`, `apache2`/`httpd -v`, `nginx -v`) with timeout, bounded I/O, no shell, no sudo
12
+ - Absent reverse proxies are simply not observed (never claimed “not installed”)
13
+
14
+ ### FIX01 hardening
15
+
16
+ - Detectors resolve allowlisted basenames from fixed trusted system directories only (realpath boundary); never `ENV["PATH"]`
17
+ - Detector evidence is basename + observation metadata only — no raw stdout/stderr
18
+ - Aggregate detector wall-clock budget; hanging children are process-group killed and reaped
19
+ - Rails `after_initialize` schedules existing background inventory refresh (does not block boot)
20
+ - PostgreSQL version uses `connection_pool.with_connection` and releases the checkout
21
+
22
+ ### FIX02 executable trust
23
+
24
+ - After realpath containment, require root-owned (uid 0) directory and executable
25
+ - Reject group/world-writable directories or executables; reject app-UID-owned material
26
+ - Ordinary `/bin` → `/usr/bin` realpath equivalence remains supported; symlink escapes fail open as not observed
27
+
28
+ ### FIX03 process lifecycle
29
+
30
+ - After `Process.spawn`, every exit path terminates/reaps the child (ensure-owned cleanup; not timeout-only)
31
+ - Fail-open cleanup tolerates ESRCH / ECHILD / EINTR; no double-wait escape
32
+
3
33
  ## 0.1.0.beta.3 (unreleased)
4
34
 
5
35
  ### 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.4** (not published yet)
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.4"
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.4
2
2
 
3
3
  Authoritative runbook for controlled beta publication of the Ruby SDK.
4
4
 
@@ -153,10 +153,10 @@ PR status may say `READY FOR HUMAN PUBLICATION REVIEW` while still listing exter
153
153
  ```bash
154
154
  git checkout main
155
155
  git pull
156
- # confirm VERSION and CHANGELOG still say 0.1.0.beta.3
156
+ # confirm VERSION and CHANGELOG still say 0.1.0.beta.4
157
157
  gem build auditea.gemspec
158
158
  # inspect package once more
159
- gem push auditea-0.1.0.beta.3.gem
159
+ gem push auditea-0.1.0.beta.4.gem
160
160
  ```
161
161
 
162
162
  Then mark CHANGELOG released with the publication date in a follow-up commit.
@@ -166,9 +166,9 @@ Then mark CHANGELOG released with the publication date in a follow-up commit.
166
166
  ## Post-publication verification
167
167
 
168
168
  ```bash
169
- gem install auditea -v 0.1.0.beta.3 --pre
169
+ gem install auditea -v 0.1.0.beta.4 --pre
170
170
  ruby -rauditea -e 'puts Auditea::VERSION'
171
- # expect: 0.1.0.beta.3
171
+ # expect: 0.1.0.beta.4
172
172
  ```
173
173
 
174
174
  Prefer installing the RubyGems artifact (not a git branch) into the controlled TEST app.
@@ -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,54 @@ 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: "os_release"
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
+
46
91
  def resolve_host_key!
47
92
  configured = Auditea.configuration.host_key.to_s.strip
48
93
  configured = ENV.fetch("AUDITEA_HOST_KEY", "").to_s.strip if configured.empty?
@@ -224,6 +269,7 @@ module Auditea
224
269
  # Drop optional fields first; keep identity + required platform.
225
270
  trimmed = inventory.dup
226
271
  trimmed.delete("label")
272
+ trimmed.delete("components")
227
273
  trimmed["hardware"] = {}
228
274
  trimmed["runtime_context"] = {}
229
275
  trimmed
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Auditea
6
+ module HostInventory
7
+ # Allowlisted local component detectors.
8
+ # Fixed argv only — no shell, no sudo, no PATH search, no caller-supplied paths.
9
+ module ComponentDetectors
10
+ # Wall-clock budget for the entire collect pass (all detectors/candidates).
11
+ AGGREGATE_TIMEOUT_SECONDS = 2.5
12
+ # Per-command cap; also clipped by remaining aggregate budget.
13
+ PER_COMMAND_TIMEOUT_SECONDS = 1.0
14
+ MAX_OUTPUT_BYTES = 2_048
15
+ ALLOWED_BASENAMES = %w[openssl apache2 httpd nginx].freeze
16
+
17
+ # Fixed trusted system directories only — never ENV["PATH"] or writable app/tmp dirs.
18
+ TRUSTED_BIN_DIRS = %w[
19
+ /usr/bin
20
+ /bin
21
+ /usr/sbin
22
+ /sbin
23
+ /usr/local/bin
24
+ /usr/local/sbin
25
+ ].freeze
26
+
27
+ DETECTORS = [
28
+ {
29
+ product: "OpenSSL",
30
+ scope: "host_component",
31
+ observation_method: "host.openssl_version",
32
+ candidates: [%w[openssl version]],
33
+ parser: :parse_openssl
34
+ },
35
+ {
36
+ product: "Apache HTTP Server",
37
+ scope: "reverse_proxy",
38
+ observation_method: "host.apache_v",
39
+ candidates: [%w[apache2 -v], %w[httpd -v]],
40
+ parser: :parse_apache
41
+ },
42
+ {
43
+ product: "nginx",
44
+ scope: "reverse_proxy",
45
+ observation_method: "host.nginx_v",
46
+ candidates: [%w[nginx -v]],
47
+ parser: :parse_nginx
48
+ }
49
+ ].freeze
50
+
51
+ module_function
52
+
53
+ def collect
54
+ deadline = monotonic_now + AGGREGATE_TIMEOUT_SECONDS
55
+ DETECTORS.filter_map { |detector| detect(detector, deadline: deadline) }
56
+ rescue StandardError
57
+ []
58
+ end
59
+
60
+ def detect(detector, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
61
+ detector.fetch(:candidates).each do |argv|
62
+ break if monotonic_now >= deadline
63
+
64
+ output = capture(argv, deadline: deadline)
65
+ next if output.nil?
66
+
67
+ version = send(detector.fetch(:parser), output)
68
+ next if version.nil? || version.empty?
69
+
70
+ return Inventory::Components.build(
71
+ product: detector.fetch(:product),
72
+ version: version,
73
+ scope: detector.fetch(:scope),
74
+ observation_method: detector.fetch(:observation_method),
75
+ evidence: {
76
+ "command" => argv.first.to_s
77
+ }
78
+ )
79
+ end
80
+ nil
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ # Resolve basename from trusted directories only; execute absolute path + fixed args.
86
+ def capture(argv, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
87
+ basename = argv.first.to_s
88
+ return nil unless ALLOWED_BASENAMES.include?(basename)
89
+
90
+ remaining = deadline - monotonic_now
91
+ return nil if remaining <= 0
92
+
93
+ executable = resolve_executable(basename)
94
+ return nil unless executable
95
+
96
+ full_argv = [executable] + argv.drop(1).map(&:to_s)
97
+ timeout_seconds = [PER_COMMAND_TIMEOUT_SECONDS, remaining].min
98
+ return nil if timeout_seconds <= 0
99
+
100
+ run_bounded(full_argv, timeout_seconds)
101
+ rescue StandardError
102
+ nil
103
+ end
104
+
105
+ def resolve_executable(basename)
106
+ return nil unless ALLOWED_BASENAMES.include?(basename)
107
+ return nil if basename.include?("/") || basename.include?("\\") || basename.include?("\0")
108
+
109
+ TRUSTED_BIN_DIRS.each do |dir|
110
+ next if dir.empty? || dir.include?("\0")
111
+
112
+ candidate = File.join(dir, basename)
113
+ next unless File.file?(candidate)
114
+ next unless File.executable?(candidate)
115
+
116
+ real_dir = begin
117
+ File.realpath(dir)
118
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
119
+ next
120
+ end
121
+
122
+ real_file = begin
123
+ File.realpath(candidate)
124
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
125
+ next
126
+ end
127
+
128
+ # Symlinks must not escape the trusted real directory boundary
129
+ # (supports ordinary /bin -> /usr/bin realpath equivalence).
130
+ next unless File.dirname(real_file) == real_dir
131
+ next unless trusted_material?(real_dir, real_file)
132
+
133
+ return real_file
134
+ end
135
+ nil
136
+ rescue StandardError
137
+ nil
138
+ end
139
+
140
+ # Trust is ownership + mode on the resolved real paths — not directory-name
141
+ # membership alone. Fail open (reject) when trust cannot be established.
142
+ #
143
+ # Policy:
144
+ # - material must be root-owned (uid 0); app-UID ownership is never enough
145
+ # - directory and executable must not be group- or world-writable
146
+ # - caller-configurable paths are never accepted
147
+ def trusted_material?(real_dir, real_file)
148
+ dir_stat = stat_path(real_dir)
149
+ file_stat = stat_path(real_file)
150
+ return false unless dir_stat && file_stat
151
+
152
+ trusted_stats?(dir_stat, file_stat)
153
+ rescue StandardError
154
+ false
155
+ end
156
+
157
+ def trusted_stats?(dir_stat, file_stat)
158
+ return false unless root_owned?(dir_stat) && root_owned?(file_stat)
159
+ return false if group_or_world_writable?(dir_stat)
160
+ return false if group_or_world_writable?(file_stat)
161
+ return false if app_user_owned_untrusted?(dir_stat)
162
+ return false if app_user_owned_untrusted?(file_stat)
163
+
164
+ true
165
+ end
166
+
167
+ def root_owned?(stat)
168
+ stat.uid.zero?
169
+ end
170
+
171
+ # Root-owned system material remains trusted even when the process euid is 0.
172
+ # Non-root owners (including the effective application user) are rejected.
173
+ def app_user_owned_untrusted?(stat)
174
+ return false if stat.uid.zero?
175
+
176
+ stat.uid == Process.euid
177
+ end
178
+
179
+ def group_or_world_writable?(stat)
180
+ (stat.mode & 0o022).nonzero?
181
+ end
182
+
183
+ def stat_path(path)
184
+ File.stat(path)
185
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
186
+ nil
187
+ end
188
+
189
+ def run_bounded(full_argv, timeout_seconds)
190
+ out_r, out_w = IO.pipe
191
+ err_r, err_w = IO.pipe
192
+ pid = nil
193
+
194
+ begin
195
+ pid = Process.spawn(
196
+ *full_argv,
197
+ in: :close,
198
+ out: out_w,
199
+ err: err_w,
200
+ pgroup: true,
201
+ close_others: true
202
+ )
203
+ out_w.close
204
+ err_w.close
205
+ out_w = err_w = nil
206
+
207
+ stdout = ""
208
+ stderr = ""
209
+ status = nil
210
+
211
+ Timeout.timeout(timeout_seconds) do
212
+ stdout = out_r.read(MAX_OUTPUT_BYTES).to_s
213
+ stderr = err_r.read(MAX_OUTPUT_BYTES).to_s
214
+ _pid, status = Process.wait2(pid)
215
+ pid = nil
216
+ end
217
+
218
+ combined = "#{stdout}\n#{stderr}".byteslice(0, MAX_OUTPUT_BYTES).to_s
219
+ return nil if combined.strip.empty?
220
+ return nil if status && !status.success? && combined !~ /version|Server version|nginx/i
221
+
222
+ combined
223
+ rescue Timeout::Error
224
+ nil
225
+ ensure
226
+ # Any post-spawn exit path (timeout, other exceptions, early return after wait)
227
+ # must account for the child while pid remains owned by this method.
228
+ if pid
229
+ begin
230
+ reap_process_group(pid)
231
+ rescue StandardError
232
+ nil
233
+ end
234
+ pid = nil
235
+ end
236
+
237
+ begin
238
+ out_w&.close
239
+ rescue StandardError
240
+ nil
241
+ end
242
+ begin
243
+ err_w&.close
244
+ rescue StandardError
245
+ nil
246
+ end
247
+ begin
248
+ out_r.close
249
+ rescue StandardError
250
+ nil
251
+ end
252
+ begin
253
+ err_r.close
254
+ rescue StandardError
255
+ nil
256
+ end
257
+ end
258
+ end
259
+
260
+ def reap_process_group(pid)
261
+ return unless pid
262
+
263
+ begin
264
+ Process.kill("KILL", -pid)
265
+ rescue Errno::ESRCH, Errno::EPERM
266
+ begin
267
+ Process.kill("KILL", pid)
268
+ rescue Errno::ESRCH, Errno::EPERM
269
+ nil
270
+ end
271
+ end
272
+
273
+ loop do
274
+ Process.waitpid(pid)
275
+ break
276
+ rescue Errno::ECHILD
277
+ break
278
+ rescue Errno::EINTR
279
+ # Child may still be exiting; retry wait.
280
+ next
281
+ end
282
+ rescue StandardError
283
+ nil
284
+ end
285
+
286
+ def parse_openssl(output)
287
+ # OpenSSL 3.0.2 15 Mar 2022
288
+ if (match = output.match(/OpenSSL\s+([0-9][^\s]*)/i))
289
+ match[1]
290
+ end
291
+ end
292
+
293
+ def parse_apache(output)
294
+ # Server version: Apache/2.4.52 (Ubuntu)
295
+ if (match = output.match(%r{Apache/([0-9][^\s)]*)}i))
296
+ match[1]
297
+ end
298
+ end
299
+
300
+ def parse_nginx(output)
301
+ # nginx version: nginx/1.18.0 (Ubuntu)
302
+ if (match = output.match(%r{nginx/([0-9][^\s)]*)}i))
303
+ match[1]
304
+ end
305
+ end
306
+
307
+ def monotonic_now
308
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
309
+ end
310
+ end
311
+ end
312
+ end
@@ -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,9 +24,10 @@ 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
@@ -50,8 +50,18 @@ module Auditea
50
50
  path.to_s == "evidence.inventory.packages"
51
51
  end
52
52
 
53
+ def inventory_components_path?(path)
54
+ path.to_s == "evidence.inventory.components"
55
+ end
56
+
53
57
  def array_limit_for(path)
54
- inventory_packages_path?(path) ? INVENTORY_PACKAGES_ARRAY_MAX : DEFAULT_ARRAY_MAX
58
+ if inventory_packages_path?(path)
59
+ INVENTORY_PACKAGES_ARRAY_MAX
60
+ elsif inventory_components_path?(path)
61
+ 40
62
+ else
63
+ DEFAULT_ARRAY_MAX
64
+ end
55
65
  end
56
66
 
57
67
  def deep_sanitize(value, depth:, path:, redacted:)
@@ -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.4"
5
5
  end
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.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - AudiTea
@@ -33,6 +33,7 @@ files:
33
33
  - lib/auditea/host_inventory.rb
34
34
  - lib/auditea/host_inventory/background_refresh.rb
35
35
  - lib/auditea/host_inventory/collector.rb
36
+ - lib/auditea/host_inventory/component_detectors.rb
36
37
  - lib/auditea/host_inventory/emitter.rb
37
38
  - lib/auditea/instrumentation/active_job.rb
38
39
  - lib/auditea/instrumentation/error_subscriber.rb
@@ -43,6 +44,8 @@ files:
43
44
  - lib/auditea/inventory.rb
44
45
  - lib/auditea/inventory/background_refresh.rb
45
46
  - lib/auditea/inventory/collector.rb
47
+ - lib/auditea/inventory/components.rb
48
+ - lib/auditea/inventory/database_server.rb
46
49
  - lib/auditea/inventory/digest.rb
47
50
  - lib/auditea/inventory/emitter.rb
48
51
  - lib/auditea/inventory/string_bound.rb