auditea 0.1.0.beta.4 → 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 +11 -1
- data/README.md +2 -2
- 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 +17 -1
- data/lib/auditea/host_inventory/component_detectors.rb +3 -0
- data/lib/auditea/host_inventory/windows_components.rb +285 -0
- data/lib/auditea/railtie.rb +27 -0
- data/lib/auditea/sanitizer.rb +29 -0
- data/lib/auditea/transport/queue.rb +78 -2
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +16 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ed0a68e5bfed09c0f764146e65c9b995d11c7070224a19fc6e682367626d0d1
|
|
4
|
+
data.tar.gz: cfdc642f25cab8125b3c8479b9aacbbaa13613650fa12a9b8160e21d92c2881f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 35cd56bc10b1010767e8078d5719639e12a056bc13aacd9472d1cda46d9679ddefba7519988af49faa26f7efd8ee664bdb1fe3f78eb27655b74c391a556406b6
|
|
7
|
+
data.tar.gz: 7911d88e0322ceba880eaeb09f0825d74079107612022aa60fbfff1e6e974a43a44b31d386ee304a5ff1d855d17ea1ca657198c4c3637eb7db6700ac2cb03dd6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.0.beta.
|
|
3
|
+
## 0.1.0.beta.5 (unreleased)
|
|
4
|
+
|
|
5
|
+
### SDK readiness diagnostic
|
|
6
|
+
|
|
7
|
+
- `Auditea.diagnostic` returns a machine-readable readiness snapshot (config flags, endpoint, token presence, release, transport health, queue counters, warnings) — never the token value
|
|
8
|
+
- `Auditea.emit_diagnostic!` emits the snapshot as a `diagnostic.sdk_ready` event (category `diagnostic`)
|
|
9
|
+
- Rails boot emits one automatic **boot/startup diagnostic observation** when configured, in asynchronous mode only (fail-open, non-blocking). In synchronous mode the automatic emission is skipped so boot never performs inline network retry work; explicit user-requested synchronous delivery is unchanged. This is not a heartbeat and does not prove ongoing process liveness.
|
|
10
|
+
- The snapshot describes transport state **before** the delivery of the diagnostic event that carries it; the first received diagnostic therefore has no prior delivery result.
|
|
11
|
+
- `Transport::Queue` now tracks `delivered` count and last delivery `outcome`/`error` (`Client#health` / `Client#stats`); batched delivery is aggregated truthfully (all-accepted vs partial/failed)
|
|
12
|
+
|
|
13
|
+
## 0.1.0.beta.4
|
|
4
14
|
|
|
5
15
|
### Platform component inventory (AUD-PLATFORM-INVENTORY01)
|
|
6
16
|
|
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.5** (proposed, not yet published)
|
|
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.5"
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
```bash
|
data/RELEASE.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Release: auditea 0.1.0.beta.
|
|
1
|
+
# Release: auditea 0.1.0.beta.5
|
|
2
2
|
|
|
3
3
|
Authoritative runbook for controlled beta publication of the Ruby SDK.
|
|
4
4
|
|
|
@@ -29,6 +29,22 @@ If AudiTea `main` advances after strict interop and before publication in a way
|
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
+
## Ruby SDK setup catalog (AudiTea Source page)
|
|
33
|
+
|
|
34
|
+
Any auditea-ruby release that changes the recommended gem version, configuration
|
|
35
|
+
keys (ENV names), defaults, or capture capabilities **MUST** evaluate — and, when
|
|
36
|
+
required, update — AudiTea's Ruby SDK setup catalog
|
|
37
|
+
(`config/auditea_sdks/ruby.yml` in `reznis/AudiTea`) before that release is
|
|
38
|
+
considered fully rollout-ready.
|
|
39
|
+
|
|
40
|
+
The catalog is the single source of truth for the setup instructions rendered on
|
|
41
|
+
the AudiTea Source page. A release whose advertised settings diverge from the
|
|
42
|
+
catalog produces stale or misleading customer onboarding instructions. Updating
|
|
43
|
+
the recommended supported version remains an intentional release action — never a
|
|
44
|
+
runtime RubyGems query.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
32
48
|
## Two interoperability gates (do not conflate)
|
|
33
49
|
|
|
34
50
|
### Gate A — pinned AudiTea envelope compatibility
|
|
@@ -153,10 +169,10 @@ PR status may say `READY FOR HUMAN PUBLICATION REVIEW` while still listing exter
|
|
|
153
169
|
```bash
|
|
154
170
|
git checkout main
|
|
155
171
|
git pull
|
|
156
|
-
# confirm VERSION and CHANGELOG still say 0.1.0.beta.
|
|
172
|
+
# confirm VERSION and CHANGELOG still say 0.1.0.beta.5
|
|
157
173
|
gem build auditea.gemspec
|
|
158
174
|
# inspect package once more
|
|
159
|
-
gem push auditea-0.1.0.beta.
|
|
175
|
+
gem push auditea-0.1.0.beta.5.gem
|
|
160
176
|
```
|
|
161
177
|
|
|
162
178
|
Then mark CHANGELOG released with the publication date in a follow-up commit.
|
|
@@ -166,9 +182,9 @@ Then mark CHANGELOG released with the publication date in a follow-up commit.
|
|
|
166
182
|
## Post-publication verification
|
|
167
183
|
|
|
168
184
|
```bash
|
|
169
|
-
gem install auditea -v 0.1.0.beta.
|
|
185
|
+
gem install auditea -v 0.1.0.beta.5 --pre
|
|
170
186
|
ruby -rauditea -e 'puts Auditea::VERSION'
|
|
171
|
-
# expect: 0.1.0.beta.
|
|
187
|
+
# expect: 0.1.0.beta.5
|
|
172
188
|
```
|
|
173
189
|
|
|
174
190
|
Prefer installing the RubyGems artifact (not a git branch) into the controlled TEST app.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Gate logic for examples/windows_inventory_smoke.rb — extracted so the exit
|
|
4
|
+
# behavior can be unit-tested without running a real Windows host.
|
|
5
|
+
module WindowsSmokeGate
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
Result = Data.define(:label, :ok)
|
|
9
|
+
|
|
10
|
+
# platform: the inventory "platform" hash (os_name / os_version).
|
|
11
|
+
# components: the inventory "components" array.
|
|
12
|
+
# require_edition: when set, the os_name must equal this exact edition.
|
|
13
|
+
# require_products: product names that MUST be present in components.
|
|
14
|
+
def checks(platform:, components:, require_edition: nil, require_products: [])
|
|
15
|
+
platform = {} unless platform.is_a?(Hash)
|
|
16
|
+
components = Array(components)
|
|
17
|
+
|
|
18
|
+
results = []
|
|
19
|
+
results << Result.new(label: "windows edition detected", ok: edition_ok?(platform["os_name"].to_s, require_edition))
|
|
20
|
+
results << Result.new(label: "os build present", ok: !platform["os_version"].to_s.strip.empty?)
|
|
21
|
+
|
|
22
|
+
Array(require_products).each do |product|
|
|
23
|
+
results << Result.new(label: "product detected: #{product}", ok: components.any? { |c|
|
|
24
|
+
c["product"].to_s == product.to_s
|
|
25
|
+
})
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
results
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def edition_ok?(edition, require_edition)
|
|
32
|
+
return false if edition.empty?
|
|
33
|
+
return false if edition == "Windows_NT" # kernel-only: edition unknown
|
|
34
|
+
|
|
35
|
+
return edition == require_edition if require_edition && !require_edition.to_s.empty?
|
|
36
|
+
|
|
37
|
+
true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def failed?(results)
|
|
41
|
+
Array(results).any? { |result| !result.ok }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Windows host-inventory smoke — run ON the Windows host (e.g. IRCM). No server needed.
|
|
4
|
+
#
|
|
5
|
+
# Validates the full detection path end-to-end:
|
|
6
|
+
# Gem.win_platform? -> windows? -> reg.exe -> parse -> components + OS edition.
|
|
7
|
+
#
|
|
8
|
+
# NEVER transmits anything to AudiTea: it calls Collector directly and prints
|
|
9
|
+
# only. Do not require AUDITEA_CAPTURE_HOST_INVENTORY=1 to run it.
|
|
10
|
+
#
|
|
11
|
+
# Usage (from an auditea-ruby checkout):
|
|
12
|
+
# bundle exec ruby examples/windows_inventory_smoke.rb
|
|
13
|
+
#
|
|
14
|
+
# Gate (exit 1 when a required expectation fails):
|
|
15
|
+
# REQUIRE_EDITION="Windows Server 2016 Standard" \
|
|
16
|
+
# REQUIRE_PRODUCTS="Apache HTTP Server,OpenSSL" \
|
|
17
|
+
# bundle exec ruby examples/windows_inventory_smoke.rb
|
|
18
|
+
#
|
|
19
|
+
# Expected on IRCM:
|
|
20
|
+
# platform.os_name == "Windows Server 2016 Standard" (NOT "Windows_NT")
|
|
21
|
+
# platform.os_version == "10.0.14393" (NOT "unknown")
|
|
22
|
+
# a component { "product" => "Apache HTTP Server", "scope" => "reverse_proxy", ... }
|
|
23
|
+
# a component { "product" => "OpenSSL", "scope" => "host_component", ... }
|
|
24
|
+
|
|
25
|
+
require "auditea"
|
|
26
|
+
require "json"
|
|
27
|
+
require_relative "support/windows_smoke_gate"
|
|
28
|
+
|
|
29
|
+
inventory = Auditea::HostInventory::Collector.collect
|
|
30
|
+
|
|
31
|
+
platform = inventory["platform"] || {}
|
|
32
|
+
components = inventory["components"] || []
|
|
33
|
+
|
|
34
|
+
puts "== platform =="
|
|
35
|
+
puts JSON.pretty_generate(platform)
|
|
36
|
+
|
|
37
|
+
puts "== sdk =="
|
|
38
|
+
puts JSON.pretty_generate(inventory["sdk"])
|
|
39
|
+
|
|
40
|
+
puts "== components (#{components.size}) =="
|
|
41
|
+
components.each { |component| puts JSON.pretty_generate(component) }
|
|
42
|
+
puts "(no components)" if components.empty?
|
|
43
|
+
|
|
44
|
+
require_edition = ENV["REQUIRE_EDITION"].to_s.strip
|
|
45
|
+
require_products = ENV["REQUIRE_PRODUCTS"].to_s.split(",").map(&:strip).reject(&:empty?)
|
|
46
|
+
|
|
47
|
+
results = WindowsSmokeGate.checks(
|
|
48
|
+
platform: platform,
|
|
49
|
+
components: components,
|
|
50
|
+
require_edition: require_edition.empty? ? nil : require_edition,
|
|
51
|
+
require_products: require_products
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
puts
|
|
55
|
+
puts "== checks =="
|
|
56
|
+
results.each do |result|
|
|
57
|
+
puts format("%-40<label>s %<verdict>s", label: result.label, verdict: (result.ok ? "PASS" : "FAIL"))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
if WindowsSmokeGate.failed?(results)
|
|
61
|
+
warn "smoke gate FAILED — see checks above."
|
|
62
|
+
exit 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
puts "smoke gate passed."
|
data/lib/auditea/client.rb
CHANGED
|
@@ -25,6 +25,16 @@ module Auditea
|
|
|
25
25
|
queue.shutdown
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Queue depth/drop/inflight counters (see Transport::Queue#stats).
|
|
29
|
+
def stats
|
|
30
|
+
queue.stats
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Recent transport health: last delivery outcome + error (see Transport::Queue#health).
|
|
34
|
+
def health
|
|
35
|
+
queue.health
|
|
36
|
+
end
|
|
37
|
+
|
|
28
38
|
def reset!
|
|
29
39
|
shutdown
|
|
30
40
|
rebuild!
|
|
@@ -20,7 +20,7 @@ module Auditea
|
|
|
20
20
|
:open_timeout, :write_timeout, :max_retries, :logger, :release, :sdk_name,
|
|
21
21
|
:capture_requests, :capture_exceptions, :capture_request_params,
|
|
22
22
|
:capture_active_job, :capture_runtime_inventory, :capture_host_inventory,
|
|
23
|
-
:inventory_refresh_interval, :host_key, :host_label,
|
|
23
|
+
:emit_boot_diagnostic, :inventory_refresh_interval, :host_key, :host_label,
|
|
24
24
|
:message_max_bytes, :nesting_max_depth
|
|
25
25
|
|
|
26
26
|
def initialize
|
|
@@ -47,6 +47,7 @@ module Auditea
|
|
|
47
47
|
@capture_runtime_inventory = truthy?(ENV.fetch("AUDITEA_CAPTURE_RUNTIME_INVENTORY", "false"))
|
|
48
48
|
# Opt-in: host/platform inventory (opaque node id + OS facts).
|
|
49
49
|
@capture_host_inventory = truthy?(ENV.fetch("AUDITEA_CAPTURE_HOST_INVENTORY", "false"))
|
|
50
|
+
@emit_boot_diagnostic = truthy?(ENV.fetch("AUDITEA_EMIT_BOOT_DIAGNOSTIC", "true"))
|
|
50
51
|
@host_key = ENV.fetch("AUDITEA_HOST_KEY", nil)
|
|
51
52
|
@host_label = ENV.fetch("AUDITEA_HOST_LABEL", nil)
|
|
52
53
|
@inventory_refresh_interval = float_env(
|
|
@@ -98,6 +99,12 @@ module Auditea
|
|
|
98
99
|
host = normalize_host(uri.host)
|
|
99
100
|
return false if host.empty?
|
|
100
101
|
|
|
102
|
+
# A base endpoint has no legitimate userinfo (credentials), query, or
|
|
103
|
+
# fragment. Reject these dangerous URL forms rather than silently
|
|
104
|
+
# supporting them — they can embed secrets in a place the generic key
|
|
105
|
+
# sanitizer cannot reach.
|
|
106
|
+
return false if uri.userinfo || uri.query || uri.fragment
|
|
107
|
+
|
|
101
108
|
case uri
|
|
102
109
|
when URI::HTTPS
|
|
103
110
|
true
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
# Builds a machine-readable readiness snapshot for the SDK: what is configured,
|
|
5
|
+
# what is missing, and recent transport health.
|
|
6
|
+
#
|
|
7
|
+
# Never includes the source token value — only its presence. Intended both for
|
|
8
|
+
# direct inspection (`Auditea.diagnostic`) and for transport as event metadata
|
|
9
|
+
# (`Auditea.emit_diagnostic!` → `diagnostic.sdk_ready`).
|
|
10
|
+
class Diagnostic
|
|
11
|
+
def self.call
|
|
12
|
+
new.call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
cfg = Auditea.configuration
|
|
17
|
+
{
|
|
18
|
+
gem_version: VERSION,
|
|
19
|
+
configured: cfg.configured?,
|
|
20
|
+
endpoint: safe_endpoint(cfg.endpoint),
|
|
21
|
+
token_present: token_present?(cfg),
|
|
22
|
+
release: presence(cfg.release),
|
|
23
|
+
enabled: cfg.enabled?,
|
|
24
|
+
async: cfg.async?,
|
|
25
|
+
raise_errors: cfg.raise_errors,
|
|
26
|
+
capture_exceptions: cfg.capture_exceptions,
|
|
27
|
+
capture_runtime_inventory: cfg.capture_runtime_inventory?,
|
|
28
|
+
capture_host_inventory: cfg.capture_host_inventory?,
|
|
29
|
+
capture_requests: cfg.capture_requests,
|
|
30
|
+
capture_request_params: cfg.capture_request_params,
|
|
31
|
+
capture_active_job: cfg.capture_active_job,
|
|
32
|
+
valid: valid?(cfg),
|
|
33
|
+
transport: Auditea.client.health,
|
|
34
|
+
queue: Auditea.client.stats,
|
|
35
|
+
warnings: warnings(cfg)
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def token_present?(cfg)
|
|
42
|
+
!cfg.source_token.to_s.strip.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Endpoint representation safe-by-construction: strips userinfo (credentials),
|
|
46
|
+
# query, and fragment so secrets embedded in the URL never enter the snapshot.
|
|
47
|
+
# Returns nil for an unparseable endpoint (nothing sensitive is exposed).
|
|
48
|
+
def safe_endpoint(value)
|
|
49
|
+
uri = URI.parse(value.to_s)
|
|
50
|
+
return nil if uri.host.to_s.empty?
|
|
51
|
+
|
|
52
|
+
uri.user = nil
|
|
53
|
+
uri.password = nil
|
|
54
|
+
uri.query = nil
|
|
55
|
+
uri.fragment = nil
|
|
56
|
+
uri.to_s
|
|
57
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def valid?(cfg)
|
|
62
|
+
cfg.validate!
|
|
63
|
+
true
|
|
64
|
+
rescue ConfigurationError
|
|
65
|
+
false
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def warnings(cfg)
|
|
69
|
+
[].tap do |warnings|
|
|
70
|
+
warnings << "source_token unset" unless token_present?(cfg)
|
|
71
|
+
warnings << "release unset — events will lack a release" if presence(cfg.release).nil?
|
|
72
|
+
if cfg.endpoint.to_s == Configuration::DEFAULT_ENDPOINT
|
|
73
|
+
warnings << "endpoint is the SDK default (#{Configuration::DEFAULT_ENDPOINT})"
|
|
74
|
+
end
|
|
75
|
+
warnings << "runtime inventory disabled" unless cfg.capture_runtime_inventory?
|
|
76
|
+
warnings << "host inventory disabled" unless cfg.capture_host_inventory?
|
|
77
|
+
warnings << "raise_errors enabled — SDK errors may raise in the application" if cfg.raise_errors
|
|
78
|
+
warnings << "SDK disabled" unless cfg.enabled?
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def presence(value)
|
|
83
|
+
value.to_s.strip.empty? ? nil : value.to_s.strip
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -70,7 +70,7 @@ module Auditea
|
|
|
70
70
|
product: product,
|
|
71
71
|
version: version,
|
|
72
72
|
scope: "host_os",
|
|
73
|
-
observation_method:
|
|
73
|
+
observation_method: host_os_provenance(platform)
|
|
74
74
|
)
|
|
75
75
|
end
|
|
76
76
|
|
|
@@ -88,6 +88,14 @@ module Auditea
|
|
|
88
88
|
list
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# The OS-edition row's provenance differs by platform: /etc/os-release on
|
|
92
|
+
# Linux, the Windows registry ProductName on Windows. Never claim os_release
|
|
93
|
+
# for a Windows host.
|
|
94
|
+
def host_os_provenance(platform)
|
|
95
|
+
platform["os_family"].to_s == "windows" ? "windows.registry" : "os_release"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :host_os_provenance
|
|
98
|
+
|
|
91
99
|
def resolve_host_key!
|
|
92
100
|
configured = Auditea.configuration.host_key.to_s.strip
|
|
93
101
|
configured = ENV.fetch("AUDITEA_HOST_KEY", "").to_s.strip if configured.empty?
|
|
@@ -172,6 +180,14 @@ module Auditea
|
|
|
172
180
|
}
|
|
173
181
|
end
|
|
174
182
|
|
|
183
|
+
if os_family == "windows"
|
|
184
|
+
return {
|
|
185
|
+
name: first_present(WindowsComponents.product_name, uname[:sysname], "Windows"),
|
|
186
|
+
# Windows kernel release doubles as the OS build (e.g. "10.0.14393").
|
|
187
|
+
version: first_present(uname[:release])
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
175
191
|
{
|
|
176
192
|
name: first_present(uname[:sysname], RbConfig::CONFIG["host_os"]),
|
|
177
193
|
# Never substitute kernel build metadata (uname.version) as product OS version.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "timeout"
|
|
4
|
+
require_relative "windows_components"
|
|
4
5
|
|
|
5
6
|
module Auditea
|
|
6
7
|
module HostInventory
|
|
@@ -51,6 +52,8 @@ module Auditea
|
|
|
51
52
|
module_function
|
|
52
53
|
|
|
53
54
|
def collect
|
|
55
|
+
return WindowsComponents.collect if WindowsComponents.windows?
|
|
56
|
+
|
|
54
57
|
deadline = monotonic_now + AGGREGATE_TIMEOUT_SECONDS
|
|
55
58
|
DETECTORS.filter_map { |detector| detect(detector, deadline: deadline) }
|
|
56
59
|
rescue StandardError
|
|
@@ -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
|
data/lib/auditea/railtie.rb
CHANGED
|
@@ -31,6 +31,33 @@ module Auditea
|
|
|
31
31
|
rescue StandardError => error
|
|
32
32
|
Auditea.handle_error(error)
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
begin
|
|
36
|
+
# One automatic boot/startup diagnostic observation when configured.
|
|
37
|
+
# Never crash boot: fully fail-open (swallow, log, continue) regardless of
|
|
38
|
+
# raise_errors.
|
|
39
|
+
Auditea::Railtie.emit_boot_diagnostic!
|
|
40
|
+
rescue StandardError => error
|
|
41
|
+
Rails.logger&.warn("[auditea] boot diagnostic skipped: #{error.class}: #{error.message.to_s.byteslice(0, 200)}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Emits a single automatic boot/startup diagnostic observation when the SDK is
|
|
46
|
+
# configured and the flag is enabled.
|
|
47
|
+
#
|
|
48
|
+
# Emitted only in asynchronous mode. In synchronous mode the automatic
|
|
49
|
+
# observation is skipped entirely: enqueue would perform inline HTTP (including
|
|
50
|
+
# retries/timeouts), adding an external-network dependency to Rails startup.
|
|
51
|
+
# This does NOT weaken explicit user-requested synchronous delivery
|
|
52
|
+
# (Auditea.emit_diagnostic! / capture with async:false still deliver inline);
|
|
53
|
+
# it only gates the automatic boot path. The local snapshot
|
|
54
|
+
# (Auditea.diagnostic) remains available regardless.
|
|
55
|
+
def self.emit_boot_diagnostic!
|
|
56
|
+
cfg = Auditea.configuration
|
|
57
|
+
return unless cfg.configured? && cfg.emit_boot_diagnostic
|
|
58
|
+
return unless cfg.async?
|
|
59
|
+
|
|
60
|
+
Auditea.emit_diagnostic!(flush: false)
|
|
34
61
|
end
|
|
35
62
|
end
|
|
36
63
|
end
|
data/lib/auditea/sanitizer.rb
CHANGED
|
@@ -16,8 +16,37 @@ module Auditea
|
|
|
16
16
|
otp otp_secret session_id credit_card card_number cvv ssn
|
|
17
17
|
].freeze
|
|
18
18
|
|
|
19
|
+
# URI credential forms are stripped from diagnostic value strings (endpoint,
|
|
20
|
+
# transport errors, warnings). This is a bounded field-specific redaction for
|
|
21
|
+
# the diagnostic contract, NOT a general secret scanner.
|
|
22
|
+
URI_USERINFO_RE = %r{(https?://)[^/\s@]+@}i
|
|
23
|
+
SENSITIVE_QUERY_RE = /
|
|
24
|
+
([?&]
|
|
25
|
+
(?:token|password|passwd|secret|api_?key|access_token|refresh_token|
|
|
26
|
+
auth|authorization|key|signature|sig|credential)
|
|
27
|
+
=
|
|
28
|
+
)
|
|
29
|
+
[^&\s]*
|
|
30
|
+
/ix
|
|
31
|
+
|
|
19
32
|
module_function
|
|
20
33
|
|
|
34
|
+
# Scrub URI userinfo (`scheme://user:pass@host`) and sensitive query values
|
|
35
|
+
# from a free-form diagnostic string, then bound its length.
|
|
36
|
+
def sanitize_diagnostic_string(value, max_bytes:)
|
|
37
|
+
return nil if value.nil?
|
|
38
|
+
|
|
39
|
+
scrub_uri_credentials(value.to_s).byteslice(0, max_bytes)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Remove `user:password@` from any embedded http(s) URL and redact the value
|
|
43
|
+
# of sensitive query parameters. Field-specific: only URI credential shapes.
|
|
44
|
+
def scrub_uri_credentials(text)
|
|
45
|
+
text.to_s
|
|
46
|
+
.gsub(URI_USERINFO_RE) { Regexp.last_match(1) }
|
|
47
|
+
.gsub(SENSITIVE_QUERY_RE) { "#{Regexp.last_match(1)}[REDACTED]" }
|
|
48
|
+
end
|
|
49
|
+
|
|
21
50
|
def sanitize_event(event)
|
|
22
51
|
redacted = []
|
|
23
52
|
sanitized = deep_sanitize(event, depth: 0, path: nil, redacted: redacted)
|
|
@@ -18,7 +18,10 @@ module Auditea
|
|
|
18
18
|
@buffer = []
|
|
19
19
|
@stopped = false
|
|
20
20
|
@dropped = 0
|
|
21
|
+
@delivered = 0
|
|
21
22
|
@inflight = 0
|
|
23
|
+
@last_outcome = nil
|
|
24
|
+
@last_error = nil
|
|
22
25
|
@thread = nil
|
|
23
26
|
@owner_pid = nil
|
|
24
27
|
end
|
|
@@ -95,7 +98,22 @@ module Auditea
|
|
|
95
98
|
|
|
96
99
|
def stats
|
|
97
100
|
ensure_process!
|
|
98
|
-
@mutex.synchronize
|
|
101
|
+
@mutex.synchronize do
|
|
102
|
+
{
|
|
103
|
+
size: @buffer.size,
|
|
104
|
+
dropped: @dropped,
|
|
105
|
+
delivered: @delivered,
|
|
106
|
+
inflight: @inflight,
|
|
107
|
+
pid: @owner_pid
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Recent transport health for diagnostics: what happened to the last delivery
|
|
113
|
+
# attempt (accepted / unauthorized / exhausted / error / …) and, on failure, why.
|
|
114
|
+
def health
|
|
115
|
+
ensure_process!
|
|
116
|
+
@mutex.synchronize { { last_outcome: @last_outcome, last_error: @last_error } }
|
|
99
117
|
end
|
|
100
118
|
|
|
101
119
|
private
|
|
@@ -106,10 +124,15 @@ module Auditea
|
|
|
106
124
|
if @owner_pid.nil?
|
|
107
125
|
@owner_pid = pid
|
|
108
126
|
elsif @owner_pid != pid
|
|
109
|
-
# Child after fork: drop inherited buffer/worker; do not deliver parent
|
|
127
|
+
# Child after fork: drop inherited buffer/worker; do not deliver parent
|
|
128
|
+
# data. Also reset the diagnostic transport counters/state so the child
|
|
129
|
+
# describes its own process, not the one it forked from.
|
|
110
130
|
@buffer = []
|
|
111
131
|
@inflight = 0
|
|
112
132
|
@dropped = 0
|
|
133
|
+
@delivered = 0
|
|
134
|
+
@last_outcome = nil
|
|
135
|
+
@last_error = nil
|
|
113
136
|
@stopped = false
|
|
114
137
|
@thread = nil
|
|
115
138
|
@owner_pid = pid
|
|
@@ -183,9 +206,11 @@ module Auditea
|
|
|
183
206
|
else
|
|
184
207
|
@http.post_batch(events)
|
|
185
208
|
end
|
|
209
|
+
record_delivery(result)
|
|
186
210
|
handle_result(events, result)
|
|
187
211
|
result
|
|
188
212
|
rescue StandardError => error
|
|
213
|
+
record_delivery_error(error)
|
|
189
214
|
@configuration.logger&.warn("[auditea] delivery failed: #{error.class}: #{error.message.to_s.byteslice(0,
|
|
190
215
|
200)}")
|
|
191
216
|
raise if @configuration.raise_errors
|
|
@@ -220,6 +245,57 @@ module Auditea
|
|
|
220
245
|
nil
|
|
221
246
|
end
|
|
222
247
|
|
|
248
|
+
def record_delivery(result)
|
|
249
|
+
delivered, outcome, error = aggregate_delivery(result)
|
|
250
|
+
@mutex.synchronize do
|
|
251
|
+
@last_outcome = outcome
|
|
252
|
+
@last_error = error
|
|
253
|
+
@delivered += delivered
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def record_delivery_error(error)
|
|
258
|
+
@mutex.synchronize do
|
|
259
|
+
@last_outcome = "error"
|
|
260
|
+
@last_error = Sanitizer.sanitize_diagnostic_string("#{error.class}: #{error.message}", max_bytes: 200)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Truthful aggregate outcome for a single-event or batch DeliveryResult.
|
|
265
|
+
# `delivered` counts only events the server accepted/treated as success.
|
|
266
|
+
def aggregate_delivery(result)
|
|
267
|
+
return [0, "accepted", nil] unless result.is_a?(DeliveryResult)
|
|
268
|
+
|
|
269
|
+
if result.results
|
|
270
|
+
rows = result.results
|
|
271
|
+
delivered = rows.count { |row| row["ok"] }
|
|
272
|
+
[delivered, aggregate_batch_outcome(rows), aggregate_batch_error(rows)]
|
|
273
|
+
else
|
|
274
|
+
delivered = result.ok? ? 1 : 0
|
|
275
|
+
error = result.ok? ? nil : Sanitizer.sanitize_diagnostic_string(result.message, max_bytes: 200)
|
|
276
|
+
[delivered, result.outcome.to_s, error]
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def aggregate_batch_outcome(rows)
|
|
281
|
+
return "error" if rows.empty?
|
|
282
|
+
|
|
283
|
+
ok = rows.count { |row| row["ok"] }
|
|
284
|
+
return "accepted" if ok == rows.size
|
|
285
|
+
|
|
286
|
+
bad_outcomes = rows.reject { |row| row["ok"] }.map { |row| row["outcome"] }.compact.uniq
|
|
287
|
+
return "partial_failure" if ok.positive?
|
|
288
|
+
|
|
289
|
+
# Total failure: surface the single uniform outcome, else a generic error.
|
|
290
|
+
bad_outcomes.size == 1 ? bad_outcomes.first : "error"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def aggregate_batch_error(rows)
|
|
294
|
+
return nil if rows.all? { |row| row["ok"] }
|
|
295
|
+
|
|
296
|
+
Sanitizer.sanitize_diagnostic_string("mixed_batch_results", max_bytes: 200)
|
|
297
|
+
end
|
|
298
|
+
|
|
223
299
|
def log_drop(event_id, reason)
|
|
224
300
|
@configuration.logger&.warn("[auditea] event_id=#{event_id} reason=#{reason}")
|
|
225
301
|
end
|
data/lib/auditea/version.rb
CHANGED
data/lib/auditea.rb
CHANGED
|
@@ -22,6 +22,7 @@ require_relative "auditea/event_builder"
|
|
|
22
22
|
require_relative "auditea/actor"
|
|
23
23
|
require_relative "auditea/target"
|
|
24
24
|
require_relative "auditea/current_context"
|
|
25
|
+
require_relative "auditea/diagnostic"
|
|
25
26
|
require_relative "auditea/transport/limits"
|
|
26
27
|
require_relative "auditea/transport/delivery_result"
|
|
27
28
|
require_relative "auditea/transport/http"
|
|
@@ -142,6 +143,21 @@ module Auditea
|
|
|
142
143
|
HostInventory.maybe_capture!
|
|
143
144
|
end
|
|
144
145
|
|
|
146
|
+
# Machine-readable readiness snapshot (config + transport health + warnings).
|
|
147
|
+
# Never includes the source token value.
|
|
148
|
+
def diagnostic
|
|
149
|
+
Diagnostic.call
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Emit the readiness snapshot as a `diagnostic.sdk_ready` event, then flush.
|
|
153
|
+
# Returns the capture result (fail-open: { ok: false, skipped: true, reason: … }
|
|
154
|
+
# when disabled or not configured).
|
|
155
|
+
def emit_diagnostic!(flush: true)
|
|
156
|
+
result = capture("diagnostic.sdk_ready", category: "diagnostic", severity: "info", metadata: Diagnostic.call)
|
|
157
|
+
flush(timeout: 10) if flush
|
|
158
|
+
result
|
|
159
|
+
end
|
|
160
|
+
|
|
145
161
|
def flush(timeout: 5)
|
|
146
162
|
client.flush(timeout: timeout)
|
|
147
163
|
rescue StandardError => error
|
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.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- AudiTea
|
|
@@ -24,17 +24,21 @@ files:
|
|
|
24
24
|
- RELEASE.md
|
|
25
25
|
- auditea.gemspec
|
|
26
26
|
- examples/manual_capture.rb
|
|
27
|
+
- examples/support/windows_smoke_gate.rb
|
|
28
|
+
- examples/windows_inventory_smoke.rb
|
|
27
29
|
- lib/auditea.rb
|
|
28
30
|
- lib/auditea/actor.rb
|
|
29
31
|
- lib/auditea/client.rb
|
|
30
32
|
- lib/auditea/configuration.rb
|
|
31
33
|
- lib/auditea/current_context.rb
|
|
34
|
+
- lib/auditea/diagnostic.rb
|
|
32
35
|
- lib/auditea/event_builder.rb
|
|
33
36
|
- lib/auditea/host_inventory.rb
|
|
34
37
|
- lib/auditea/host_inventory/background_refresh.rb
|
|
35
38
|
- lib/auditea/host_inventory/collector.rb
|
|
36
39
|
- lib/auditea/host_inventory/component_detectors.rb
|
|
37
40
|
- lib/auditea/host_inventory/emitter.rb
|
|
41
|
+
- lib/auditea/host_inventory/windows_components.rb
|
|
38
42
|
- lib/auditea/instrumentation/active_job.rb
|
|
39
43
|
- lib/auditea/instrumentation/error_subscriber.rb
|
|
40
44
|
- lib/auditea/instrumentation/exception.rb
|