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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +40 -0
- data/README.md +16 -3
- data/RELEASE.md +21 -5
- data/examples/support/windows_smoke_gate.rb +43 -0
- data/examples/windows_inventory_smoke.rb +65 -0
- data/lib/auditea/client.rb +10 -0
- data/lib/auditea/configuration.rb +8 -1
- data/lib/auditea/diagnostic.rb +86 -0
- data/lib/auditea/host_inventory/collector.rb +63 -1
- data/lib/auditea/host_inventory/component_detectors.rb +315 -0
- data/lib/auditea/host_inventory/windows_components.rb +285 -0
- data/lib/auditea/inventory/collector.rb +126 -0
- data/lib/auditea/inventory/components.rb +80 -0
- data/lib/auditea/inventory/database_server.rb +57 -0
- data/lib/auditea/railtie.rb +31 -3
- data/lib/auditea/sanitizer.rb +40 -1
- data/lib/auditea/transport/queue.rb +78 -2
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +16 -0
- metadata +8 -1
|
@@ -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
|