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.
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "windows_components"
5
+
6
+ module Auditea
7
+ module HostInventory
8
+ # Allowlisted local component detectors.
9
+ # Fixed argv only — no shell, no sudo, no PATH search, no caller-supplied paths.
10
+ module ComponentDetectors
11
+ # Wall-clock budget for the entire collect pass (all detectors/candidates).
12
+ AGGREGATE_TIMEOUT_SECONDS = 2.5
13
+ # Per-command cap; also clipped by remaining aggregate budget.
14
+ PER_COMMAND_TIMEOUT_SECONDS = 1.0
15
+ MAX_OUTPUT_BYTES = 2_048
16
+ ALLOWED_BASENAMES = %w[openssl apache2 httpd nginx].freeze
17
+
18
+ # Fixed trusted system directories only — never ENV["PATH"] or writable app/tmp dirs.
19
+ TRUSTED_BIN_DIRS = %w[
20
+ /usr/bin
21
+ /bin
22
+ /usr/sbin
23
+ /sbin
24
+ /usr/local/bin
25
+ /usr/local/sbin
26
+ ].freeze
27
+
28
+ DETECTORS = [
29
+ {
30
+ product: "OpenSSL",
31
+ scope: "host_component",
32
+ observation_method: "host.openssl_version",
33
+ candidates: [%w[openssl version]],
34
+ parser: :parse_openssl
35
+ },
36
+ {
37
+ product: "Apache HTTP Server",
38
+ scope: "reverse_proxy",
39
+ observation_method: "host.apache_v",
40
+ candidates: [%w[apache2 -v], %w[httpd -v]],
41
+ parser: :parse_apache
42
+ },
43
+ {
44
+ product: "nginx",
45
+ scope: "reverse_proxy",
46
+ observation_method: "host.nginx_v",
47
+ candidates: [%w[nginx -v]],
48
+ parser: :parse_nginx
49
+ }
50
+ ].freeze
51
+
52
+ module_function
53
+
54
+ def collect
55
+ return WindowsComponents.collect if WindowsComponents.windows?
56
+
57
+ deadline = monotonic_now + AGGREGATE_TIMEOUT_SECONDS
58
+ DETECTORS.filter_map { |detector| detect(detector, deadline: deadline) }
59
+ rescue StandardError
60
+ []
61
+ end
62
+
63
+ def detect(detector, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
64
+ detector.fetch(:candidates).each do |argv|
65
+ break if monotonic_now >= deadline
66
+
67
+ output = capture(argv, deadline: deadline)
68
+ next if output.nil?
69
+
70
+ version = send(detector.fetch(:parser), output)
71
+ next if version.nil? || version.empty?
72
+
73
+ return Inventory::Components.build(
74
+ product: detector.fetch(:product),
75
+ version: version,
76
+ scope: detector.fetch(:scope),
77
+ observation_method: detector.fetch(:observation_method),
78
+ evidence: {
79
+ "command" => argv.first.to_s
80
+ }
81
+ )
82
+ end
83
+ nil
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ # Resolve basename from trusted directories only; execute absolute path + fixed args.
89
+ def capture(argv, deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
90
+ basename = argv.first.to_s
91
+ return nil unless ALLOWED_BASENAMES.include?(basename)
92
+
93
+ remaining = deadline - monotonic_now
94
+ return nil if remaining <= 0
95
+
96
+ executable = resolve_executable(basename)
97
+ return nil unless executable
98
+
99
+ full_argv = [executable] + argv.drop(1).map(&:to_s)
100
+ timeout_seconds = [PER_COMMAND_TIMEOUT_SECONDS, remaining].min
101
+ return nil if timeout_seconds <= 0
102
+
103
+ run_bounded(full_argv, timeout_seconds)
104
+ rescue StandardError
105
+ nil
106
+ end
107
+
108
+ def resolve_executable(basename)
109
+ return nil unless ALLOWED_BASENAMES.include?(basename)
110
+ return nil if basename.include?("/") || basename.include?("\\") || basename.include?("\0")
111
+
112
+ TRUSTED_BIN_DIRS.each do |dir|
113
+ next if dir.empty? || dir.include?("\0")
114
+
115
+ candidate = File.join(dir, basename)
116
+ next unless File.file?(candidate)
117
+ next unless File.executable?(candidate)
118
+
119
+ real_dir = begin
120
+ File.realpath(dir)
121
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
122
+ next
123
+ end
124
+
125
+ real_file = begin
126
+ File.realpath(candidate)
127
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
128
+ next
129
+ end
130
+
131
+ # Symlinks must not escape the trusted real directory boundary
132
+ # (supports ordinary /bin -> /usr/bin realpath equivalence).
133
+ next unless File.dirname(real_file) == real_dir
134
+ next unless trusted_material?(real_dir, real_file)
135
+
136
+ return real_file
137
+ end
138
+ nil
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ # Trust is ownership + mode on the resolved real paths — not directory-name
144
+ # membership alone. Fail open (reject) when trust cannot be established.
145
+ #
146
+ # Policy:
147
+ # - material must be root-owned (uid 0); app-UID ownership is never enough
148
+ # - directory and executable must not be group- or world-writable
149
+ # - caller-configurable paths are never accepted
150
+ def trusted_material?(real_dir, real_file)
151
+ dir_stat = stat_path(real_dir)
152
+ file_stat = stat_path(real_file)
153
+ return false unless dir_stat && file_stat
154
+
155
+ trusted_stats?(dir_stat, file_stat)
156
+ rescue StandardError
157
+ false
158
+ end
159
+
160
+ def trusted_stats?(dir_stat, file_stat)
161
+ return false unless root_owned?(dir_stat) && root_owned?(file_stat)
162
+ return false if group_or_world_writable?(dir_stat)
163
+ return false if group_or_world_writable?(file_stat)
164
+ return false if app_user_owned_untrusted?(dir_stat)
165
+ return false if app_user_owned_untrusted?(file_stat)
166
+
167
+ true
168
+ end
169
+
170
+ def root_owned?(stat)
171
+ stat.uid.zero?
172
+ end
173
+
174
+ # Root-owned system material remains trusted even when the process euid is 0.
175
+ # Non-root owners (including the effective application user) are rejected.
176
+ def app_user_owned_untrusted?(stat)
177
+ return false if stat.uid.zero?
178
+
179
+ stat.uid == Process.euid
180
+ end
181
+
182
+ def group_or_world_writable?(stat)
183
+ (stat.mode & 0o022).nonzero?
184
+ end
185
+
186
+ def stat_path(path)
187
+ File.stat(path)
188
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
189
+ nil
190
+ end
191
+
192
+ def run_bounded(full_argv, timeout_seconds)
193
+ out_r, out_w = IO.pipe
194
+ err_r, err_w = IO.pipe
195
+ pid = nil
196
+
197
+ begin
198
+ pid = Process.spawn(
199
+ *full_argv,
200
+ in: :close,
201
+ out: out_w,
202
+ err: err_w,
203
+ pgroup: true,
204
+ close_others: true
205
+ )
206
+ out_w.close
207
+ err_w.close
208
+ out_w = err_w = nil
209
+
210
+ stdout = ""
211
+ stderr = ""
212
+ status = nil
213
+
214
+ Timeout.timeout(timeout_seconds) do
215
+ stdout = out_r.read(MAX_OUTPUT_BYTES).to_s
216
+ stderr = err_r.read(MAX_OUTPUT_BYTES).to_s
217
+ _pid, status = Process.wait2(pid)
218
+ pid = nil
219
+ end
220
+
221
+ combined = "#{stdout}\n#{stderr}".byteslice(0, MAX_OUTPUT_BYTES).to_s
222
+ return nil if combined.strip.empty?
223
+ return nil if status && !status.success? && combined !~ /version|Server version|nginx/i
224
+
225
+ combined
226
+ rescue Timeout::Error
227
+ nil
228
+ ensure
229
+ # Any post-spawn exit path (timeout, other exceptions, early return after wait)
230
+ # must account for the child while pid remains owned by this method.
231
+ if pid
232
+ begin
233
+ reap_process_group(pid)
234
+ rescue StandardError
235
+ nil
236
+ end
237
+ pid = nil
238
+ end
239
+
240
+ begin
241
+ out_w&.close
242
+ rescue StandardError
243
+ nil
244
+ end
245
+ begin
246
+ err_w&.close
247
+ rescue StandardError
248
+ nil
249
+ end
250
+ begin
251
+ out_r.close
252
+ rescue StandardError
253
+ nil
254
+ end
255
+ begin
256
+ err_r.close
257
+ rescue StandardError
258
+ nil
259
+ end
260
+ end
261
+ end
262
+
263
+ def reap_process_group(pid)
264
+ return unless pid
265
+
266
+ begin
267
+ Process.kill("KILL", -pid)
268
+ rescue Errno::ESRCH, Errno::EPERM
269
+ begin
270
+ Process.kill("KILL", pid)
271
+ rescue Errno::ESRCH, Errno::EPERM
272
+ nil
273
+ end
274
+ end
275
+
276
+ loop do
277
+ Process.waitpid(pid)
278
+ break
279
+ rescue Errno::ECHILD
280
+ break
281
+ rescue Errno::EINTR
282
+ # Child may still be exiting; retry wait.
283
+ next
284
+ end
285
+ rescue StandardError
286
+ nil
287
+ end
288
+
289
+ def parse_openssl(output)
290
+ # OpenSSL 3.0.2 15 Mar 2022
291
+ if (match = output.match(/OpenSSL\s+([0-9][^\s]*)/i))
292
+ match[1]
293
+ end
294
+ end
295
+
296
+ def parse_apache(output)
297
+ # Server version: Apache/2.4.52 (Ubuntu)
298
+ if (match = output.match(%r{Apache/([0-9][^\s)]*)}i))
299
+ match[1]
300
+ end
301
+ end
302
+
303
+ def parse_nginx(output)
304
+ # nginx version: nginx/1.18.0 (Ubuntu)
305
+ if (match = output.match(%r{nginx/([0-9][^\s)]*)}i))
306
+ match[1]
307
+ end
308
+ end
309
+
310
+ def monotonic_now
311
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "../inventory/components"
5
+
6
+ module Auditea
7
+ module HostInventory
8
+ # Windows-only host component detection via read-only registry queries.
9
+ #
10
+ # Windows binaries live in untrusted app-specific directories, so we never
11
+ # execute the installed product itself. Instead we read the uninstall registry
12
+ # (fixed `reg.exe query` argv — no shell, no PATH search, no caller paths) to
13
+ # observe installed products, and read the OS ProductName for edition identity.
14
+ module WindowsComponents
15
+ AGGREGATE_TIMEOUT_SECONDS = 2.5
16
+ PER_COMMAND_TIMEOUT_SECONDS = 1.0
17
+ # Total accumulated bytes we retain per stream. Output beyond this is still
18
+ # drained (so the child never blocks on a full pipe) but not accumulated.
19
+ # A real uninstall tree can exceed a few KiB; 1 MiB is ample headroom while
20
+ # still keeping the reader's memory bounded.
21
+ REG_MAX_OUTPUT = 1_048_576
22
+ REG_READ_CHUNK = 8_192
23
+ REG_DIRS = ["C:\\Windows\\System32", "C:\\Windows\\SysWOW64"].freeze
24
+ PRODUCT_NAME_KEY = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
25
+ # Single canonical key; access both 32-bit and 64-bit registry views
26
+ # explicitly instead of relying on implicit WOW64 redirection.
27
+ UNINSTALL_KEY = 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
28
+ UNINSTALL_VIEWS = %w[/reg:64 /reg:32].freeze
29
+
30
+ # DisplayName → canonical component. Read-only: never executes the product binary.
31
+ PRODUCT_MATCHERS = [
32
+ {
33
+ pattern: /\Aapache\s+http\s+server/i,
34
+ product: "Apache HTTP Server",
35
+ scope: "reverse_proxy",
36
+ method: "windows.uninstall_registry"
37
+ },
38
+ {
39
+ pattern: /\Anginx/i,
40
+ product: "nginx",
41
+ scope: "reverse_proxy",
42
+ method: "windows.uninstall_registry"
43
+ },
44
+ {
45
+ pattern: /\Aopenssl/i,
46
+ product: "OpenSSL",
47
+ scope: "host_component",
48
+ method: "windows.uninstall_registry"
49
+ }
50
+ ].freeze
51
+
52
+ # Generic Windows software channel. We observe "installed in the Windows
53
+ # uninstall registry" — the specific installer technology (MSI/Chocolatey/
54
+ # winget) is unknown, so we claim only the generic Windows ecosystem and let
55
+ # the server route it to the Windows vendor channel.
56
+ WINDOWS_PACKAGE = { "ecosystem" => "windows" }.freeze
57
+
58
+ module_function
59
+
60
+ def windows?
61
+ Gem.win_platform? || RbConfig::CONFIG["host_os"].to_s.match?(/mswin|mingw|cygwin/i)
62
+ end
63
+
64
+ # Deterministic read-only query plan: the canonical uninstall key under
65
+ # both explicit registry views. Exposed for testing and dedup guarantees.
66
+ def uninstall_queries
67
+ UNINSTALL_VIEWS.map { |view| ["query", UNINSTALL_KEY, "/s", view] }
68
+ end
69
+
70
+ def collect(deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
71
+ seen = {}
72
+ uninstall_queries.each do |argv|
73
+ remaining = deadline - monotonic_now
74
+ break if remaining <= 0
75
+
76
+ output = run_reg(argv, timeout: [PER_COMMAND_TIMEOUT_SECONDS, remaining].min)
77
+ next if output.nil?
78
+
79
+ parse_uninstall(output).each do |(name, version)|
80
+ matcher = PRODUCT_MATCHERS.find { |m| name.to_s.match?(m[:pattern]) }
81
+ next unless matcher
82
+
83
+ # Dedup only identical observations. Key on product + version + scope
84
+ # so multiple installed versions of the same product are all preserved
85
+ # (the hidden one may be the vulnerable one); a repeated identical
86
+ # version across the 32/64-bit views collapses to a single component.
87
+ key = [matcher[:product], version, matcher[:scope]]
88
+ next if seen.key?(key)
89
+
90
+ seen[key] = Inventory::Components.build(
91
+ product: matcher[:product],
92
+ version: version,
93
+ scope: matcher[:scope],
94
+ observation_method: matcher[:method],
95
+ package: WINDOWS_PACKAGE,
96
+ evidence: { "command" => "reg.exe" }
97
+ )
98
+ end
99
+ end
100
+ seen.values
101
+ end
102
+
103
+ def parse_uninstall(output)
104
+ entries = []
105
+ name = nil
106
+ version = nil
107
+ output.to_s.each_line do |line|
108
+ stripped = line.strip
109
+ if stripped.start_with?("HKEY_")
110
+ entries << [name, version] if name && version
111
+ name = nil
112
+ version = nil
113
+ next
114
+ end
115
+
116
+ if (match = stripped.match(/\ADisplayName\s+REG_\S+\s+(.+)\z/i))
117
+ name = match[1].to_s.strip
118
+ elsif (match = stripped.match(/\ADisplayVersion\s+REG_\S+\s+(.+)\z/i))
119
+ version = match[1].to_s.strip
120
+ end
121
+ end
122
+ entries << [name, version] if name && version
123
+ entries
124
+ end
125
+
126
+ # ProductName from HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion
127
+ # (e.g. "Windows Server 2016 Standard"). Fails open → nil when absent.
128
+ def product_name
129
+ read_value(PRODUCT_NAME_KEY, "ProductName")
130
+ end
131
+
132
+ def read_value(key, name)
133
+ return nil unless windows?
134
+
135
+ output = run_reg(["query", key, "/v", name])
136
+ return nil if output.nil?
137
+
138
+ parse_value(output, name)
139
+ end
140
+
141
+ def parse_value(output, name)
142
+ pattern = /\A\s*#{Regexp.escape(name)}\s+REG_\S+\s+(.+)\z/i
143
+ output.to_s.each_line do |line|
144
+ if (match = line.strip.match(pattern))
145
+ return match[1].to_s.strip
146
+ end
147
+ end
148
+ nil
149
+ end
150
+
151
+ # reg.exe lives in a fixed trusted system directory; argv is fixed and
152
+ # read-only (`query`). No PATH search, no shell, no caller-supplied paths.
153
+ # Fail-open: any registry-execution failure (missing exe, EACCES/AppLocker,
154
+ # spawn failure) yields nil so host inventory never breaks.
155
+ def run_reg(args, timeout: PER_COMMAND_TIMEOUT_SECONDS)
156
+ reg = resolve_reg_exe
157
+ return nil unless reg
158
+ return nil if timeout.to_f <= 0
159
+
160
+ run_bounded([reg] + Array(args).map(&:to_s), timeout)
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def resolve_reg_exe
166
+ REG_DIRS.each do |dir|
167
+ candidate = File.join(dir, "reg.exe")
168
+ return candidate if File.file?(candidate)
169
+ end
170
+ nil
171
+ rescue StandardError
172
+ nil
173
+ end
174
+
175
+ # Executes the fixed argv and returns combined stdout/stderr, draining BOTH
176
+ # streams concurrently to EOF so a large uninstall tree cannot block the
177
+ # child on a full pipe. Accumulated memory is bounded at REG_MAX_OUTPUT per
178
+ # stream; beyond that we keep draining (discard) so the child still exits.
179
+ # Returns nil on timeout, non-zero exit, empty output, or spawn failure.
180
+ def run_bounded(full_argv, timeout_seconds)
181
+ out_r, out_w = IO.pipe
182
+ err_r, err_w = IO.pipe
183
+ pid = nil
184
+ readers = []
185
+
186
+ begin
187
+ pid = spawn_process(full_argv, out_w, err_w)
188
+ out_w.close
189
+ err_w.close
190
+ out_w = err_w = nil
191
+
192
+ stdout = +"".b
193
+ stderr = +"".b
194
+ readers = [
195
+ Thread.new { stdout = drain_io(out_r) },
196
+ Thread.new { stderr = drain_io(err_r) }
197
+ ]
198
+
199
+ status = nil
200
+ Timeout.timeout(timeout_seconds) do
201
+ _pid, status = Process.wait2(pid)
202
+ pid = nil
203
+ end
204
+ readers.each(&:join)
205
+
206
+ combined = "#{stdout}\n#{stderr}"
207
+ return nil if combined.strip.empty?
208
+ return nil if status && !status.success?
209
+
210
+ combined
211
+ rescue Timeout::Error, SystemCallError, IOError
212
+ nil
213
+ ensure
214
+ if pid
215
+ reap(pid)
216
+ pid = nil
217
+ end
218
+
219
+ close_io(out_w)
220
+ close_io(err_w)
221
+ close_io(out_r)
222
+ close_io(err_r)
223
+ readers.each { |reader| reader.join(0.1) }
224
+ end
225
+ end
226
+
227
+ # Reads a stream to EOF in bounded chunks, retaining at most REG_MAX_OUTPUT
228
+ # bytes but continuing to drain the rest so the writer never blocks.
229
+ def drain_io(io)
230
+ out = +"".b
231
+ loop do
232
+ chunk = io.read(REG_READ_CHUNK)
233
+ break if chunk.nil?
234
+ next if out.bytesize >= REG_MAX_OUTPUT
235
+
236
+ out << chunk
237
+ out = out.byteslice(0, REG_MAX_OUTPUT) if out.bytesize > REG_MAX_OUTPUT
238
+ end
239
+ out
240
+ rescue IOError, Errno::EIO, Errno::EBADF
241
+ out
242
+ end
243
+
244
+ # Windows spawns in a new process group (`new_pgroup:`), POSIX uses `pgroup:`.
245
+ # The POSIX branch also allows the Windows reader to be integration-tested on
246
+ # Linux against a fixture executable.
247
+ def spawn_process(full_argv, out_w, err_w)
248
+ if windows?
249
+ Process.spawn(*full_argv, in: :close, out: out_w, err: err_w, new_pgroup: true)
250
+ else
251
+ Process.spawn(*full_argv, in: :close, out: out_w, err: err_w, pgroup: true)
252
+ end
253
+ end
254
+
255
+ def reap(pid)
256
+ begin
257
+ Process.kill("KILL", pid)
258
+ rescue StandardError
259
+ nil
260
+ end
261
+
262
+ loop do
263
+ Process.waitpid(pid)
264
+ break
265
+ rescue Errno::ECHILD
266
+ break
267
+ rescue Errno::EINTR
268
+ next
269
+ end
270
+ rescue StandardError
271
+ nil
272
+ end
273
+
274
+ def close_io(io)
275
+ io&.close
276
+ rescue StandardError
277
+ nil
278
+ end
279
+
280
+ def monotonic_now
281
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
282
+ end
283
+ end
284
+ end
285
+ end