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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +16 -3
- data/RELEASE.md +5 -5
- data/lib/auditea/host_inventory/collector.rb +47 -1
- data/lib/auditea/host_inventory/component_detectors.rb +312 -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 +4 -3
- data/lib/auditea/sanitizer.rb +11 -1
- data/lib/auditea/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5459c8f22551f101e1aa2f32689bf47623c3ec2890f448ff5dd92c4980ee8c91
|
|
4
|
+
data.tar.gz: 2fb620c50bc457db6f85df2d1745b1c761e3141571a14451caef9080db109e7c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 =
|
|
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
|
data/lib/auditea/railtie.rb
CHANGED
|
@@ -24,9 +24,10 @@ module Auditea
|
|
|
24
24
|
config.after_initialize do
|
|
25
25
|
at_exit { Auditea.shutdown }
|
|
26
26
|
begin
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
Auditea.
|
|
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
|
data/lib/auditea/sanitizer.rb
CHANGED
|
@@ -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)
|
|
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:)
|
data/lib/auditea/version.rb
CHANGED
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.
|
|
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
|