auditea 0.1.0.beta.4 → 0.1.0.beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5459c8f22551f101e1aa2f32689bf47623c3ec2890f448ff5dd92c4980ee8c91
4
- data.tar.gz: 2fb620c50bc457db6f85df2d1745b1c761e3141571a14451caef9080db109e7c
3
+ metadata.gz: a53fe361e4064b6efcdc0102f09a27a356f76d116b88b531774e93e1ffaafeb6
4
+ data.tar.gz: be811c131585c9ba97cdf4cef22c57309d293af0709b1b65ef756a44602d50e3
5
5
  SHA512:
6
- metadata.gz: faf9f0876938d536fe4aad106bba8b13ee72031ecb78c14d259a9a313eb046c84601b2b0ec3af9cb510d0b9581c7eb142aea169170f364e813b79ef7ae65f32d
7
- data.tar.gz: df66f3cab946bb98383c903d550f4f7cf842386e4fea0b2fb11b6d037b4c0a2beee1d652c3381199e9c35a30c743a17039844b68c78494429fc64d04448fbc96
6
+ metadata.gz: 270fabedbb574108c32286ae01149d38e23ad17c3e28fd65abaf924d77f9244172807225e0e0c013a59551514ff82372ec1134ef75ad02b16c605cd87198e3dd
7
+ data.tar.gz: e42afa22d152260e21f3826075b2b2b1a07a0bedf78541bc320020b439dc23cb601f853e790606f847c124879c9331e1a59ef97e17593a387294cb2d10cc7bce
data/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.0.beta.4 (unreleased)
3
+ ## 0.1.0.beta.6 (unreleased)
4
+
5
+ ### Windows host inventory (additive)
6
+
7
+ - Conservative Windows OS edition detection for host inventory posture (`WindowsOs` consumers on the server)
8
+ - Conservative read-only Windows Apache service discovery (`AUD-SDK-APACHE-SERVICE01`) — service-name observation only; never claims Apache is absent when not observed
9
+ - Windows smoke helpers under `examples/` for controlled field validation (IRCM native smoke deferred to a separate scheduled run)
10
+
11
+ ## 0.1.0.beta.5
12
+
13
+ ### SDK readiness diagnostic
14
+
15
+ - `Auditea.diagnostic` returns a machine-readable readiness snapshot (config flags, endpoint, token presence, release, transport health, queue counters, warnings) — never the token value
16
+ - `Auditea.emit_diagnostic!` emits the snapshot as a `diagnostic.sdk_ready` event (category `diagnostic`)
17
+ - 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.
18
+ - 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.
19
+ - `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)
20
+
21
+ ## 0.1.0.beta.4
4
22
 
5
23
  ### Platform component inventory (AUD-PLATFORM-INVENTORY01)
6
24
 
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.4** (not published yet)
5
+ Version: **0.1.0.beta.6** (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.4"
15
+ gem "auditea", "0.1.0.beta.6"
16
16
  ```
17
17
 
18
18
  ```bash
data/RELEASE.md CHANGED
@@ -1,4 +1,4 @@
1
- # Release: auditea 0.1.0.beta.4
1
+ # Release: auditea 0.1.0.beta.6
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.4
172
+ # confirm VERSION and CHANGELOG still say 0.1.0.beta.6
157
173
  gem build auditea.gemspec
158
174
  # inspect package once more
159
- gem push auditea-0.1.0.beta.4.gem
175
+ gem push auditea-0.1.0.beta.6.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.4 --pre
185
+ gem install auditea -v 0.1.0.beta.6 --pre
170
186
  ruby -rauditea -e 'puts Auditea::VERSION'
171
- # expect: 0.1.0.beta.4
187
+ # expect: 0.1.0.beta.6
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."
@@ -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: "os_release"
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,296 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "../inventory/components"
5
+ require_relative "windows_services"
6
+
7
+ module Auditea
8
+ module HostInventory
9
+ # Windows-only host component detection via read-only registry queries.
10
+ #
11
+ # Windows binaries live in untrusted app-specific directories, so we never
12
+ # execute the installed product itself. Instead we read the uninstall registry
13
+ # (fixed `reg.exe query` argv — no shell, no PATH search, no caller paths) to
14
+ # observe installed products, and read the OS ProductName for edition identity.
15
+ module WindowsComponents
16
+ AGGREGATE_TIMEOUT_SECONDS = 2.5
17
+ PER_COMMAND_TIMEOUT_SECONDS = 1.0
18
+ # Total accumulated bytes we retain per stream. Output beyond this is still
19
+ # drained (so the child never blocks on a full pipe) but not accumulated.
20
+ # A real uninstall tree can exceed a few KiB; 1 MiB is ample headroom while
21
+ # still keeping the reader's memory bounded.
22
+ REG_MAX_OUTPUT = 1_048_576
23
+ REG_READ_CHUNK = 8_192
24
+ REG_DIRS = ["C:\\Windows\\System32", "C:\\Windows\\SysWOW64"].freeze
25
+ PRODUCT_NAME_KEY = 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
26
+ # Single canonical key; access both 32-bit and 64-bit registry views
27
+ # explicitly instead of relying on implicit WOW64 redirection.
28
+ UNINSTALL_KEY = 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall'
29
+ UNINSTALL_VIEWS = %w[/reg:64 /reg:32].freeze
30
+
31
+ # DisplayName → canonical component. Read-only: never executes the product binary.
32
+ PRODUCT_MATCHERS = [
33
+ {
34
+ pattern: /\Aapache\s+http\s+server/i,
35
+ product: "Apache HTTP Server",
36
+ scope: "reverse_proxy",
37
+ method: "windows.uninstall_registry"
38
+ },
39
+ {
40
+ pattern: /\Anginx/i,
41
+ product: "nginx",
42
+ scope: "reverse_proxy",
43
+ method: "windows.uninstall_registry"
44
+ },
45
+ {
46
+ pattern: /\Aopenssl/i,
47
+ product: "OpenSSL",
48
+ scope: "host_component",
49
+ method: "windows.uninstall_registry"
50
+ }
51
+ ].freeze
52
+
53
+ # Generic Windows software channel. We observe "installed in the Windows
54
+ # uninstall registry" — the specific installer technology (MSI/Chocolatey/
55
+ # winget) is unknown, so we claim only the generic Windows ecosystem and let
56
+ # the server route it to the Windows vendor channel.
57
+ WINDOWS_PACKAGE = { "ecosystem" => "windows" }.freeze
58
+
59
+ module_function
60
+
61
+ def windows?
62
+ Gem.win_platform? || RbConfig::CONFIG["host_os"].to_s.match?(/mswin|mingw|cygwin/i)
63
+ end
64
+
65
+ # Deterministic read-only query plan: the canonical uninstall key under
66
+ # both explicit registry views. Exposed for testing and dedup guarantees.
67
+ def uninstall_queries
68
+ UNINSTALL_VIEWS.map { |view| ["query", UNINSTALL_KEY, "/s", view] }
69
+ end
70
+
71
+ def collect(deadline: monotonic_now + AGGREGATE_TIMEOUT_SECONDS)
72
+ seen = {}
73
+ uninstall_queries.each do |argv|
74
+ remaining = deadline - monotonic_now
75
+ break if remaining <= 0
76
+
77
+ output = run_reg(argv, timeout: [PER_COMMAND_TIMEOUT_SECONDS, remaining].min)
78
+ next if output.nil?
79
+
80
+ parse_uninstall(output).each do |(name, version)|
81
+ matcher = PRODUCT_MATCHERS.find { |m| name.to_s.match?(m[:pattern]) }
82
+ next unless matcher
83
+
84
+ # Dedup only identical observations. Key on product + version + scope
85
+ # so multiple installed versions of the same product are all preserved
86
+ # (the hidden one may be the vulnerable one); a repeated identical
87
+ # version across the 32/64-bit views collapses to a single component.
88
+ key = [matcher[:product], version, matcher[:scope]]
89
+ next if seen.key?(key)
90
+
91
+ seen[key] = Inventory::Components.build(
92
+ product: matcher[:product],
93
+ version: version,
94
+ scope: matcher[:scope],
95
+ observation_method: matcher[:method],
96
+ package: WINDOWS_PACKAGE,
97
+ evidence: { "command" => "reg.exe" }
98
+ )
99
+ end
100
+ end
101
+
102
+ components = seen.values
103
+ # Service discovery is a fallback for products the uninstall registry
104
+ # misses (manually unpacked ZIP, service-only install). When uninstall
105
+ # already proves an Apache HTTP Server installation (with a version), do
106
+ # not add a version-less service observation for the same installation.
107
+ unless components.any? { |c| c["product"] == "Apache HTTP Server" }
108
+ components += WindowsServices.collect(deadline: deadline)
109
+ end
110
+
111
+ components
112
+ end
113
+
114
+ def parse_uninstall(output)
115
+ entries = []
116
+ name = nil
117
+ version = nil
118
+ output.to_s.each_line do |line|
119
+ stripped = line.strip
120
+ if stripped.start_with?("HKEY_")
121
+ entries << [name, version] if name && version
122
+ name = nil
123
+ version = nil
124
+ next
125
+ end
126
+
127
+ if (match = stripped.match(/\ADisplayName\s+REG_\S+\s+(.+)\z/i))
128
+ name = match[1].to_s.strip
129
+ elsif (match = stripped.match(/\ADisplayVersion\s+REG_\S+\s+(.+)\z/i))
130
+ version = match[1].to_s.strip
131
+ end
132
+ end
133
+ entries << [name, version] if name && version
134
+ entries
135
+ end
136
+
137
+ # ProductName from HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion
138
+ # (e.g. "Windows Server 2016 Standard"). Fails open → nil when absent.
139
+ def product_name
140
+ read_value(PRODUCT_NAME_KEY, "ProductName")
141
+ end
142
+
143
+ def read_value(key, name)
144
+ return nil unless windows?
145
+
146
+ output = run_reg(["query", key, "/v", name])
147
+ return nil if output.nil?
148
+
149
+ parse_value(output, name)
150
+ end
151
+
152
+ def parse_value(output, name)
153
+ pattern = /\A\s*#{Regexp.escape(name)}\s+REG_\S+\s+(.+)\z/i
154
+ output.to_s.each_line do |line|
155
+ if (match = line.strip.match(pattern))
156
+ return match[1].to_s.strip
157
+ end
158
+ end
159
+ nil
160
+ end
161
+
162
+ # reg.exe lives in a fixed trusted system directory; argv is fixed and
163
+ # read-only (`query`). No PATH search, no shell, no caller-supplied paths.
164
+ # Fail-open: any registry-execution failure (missing exe, EACCES/AppLocker,
165
+ # spawn failure) yields nil so host inventory never breaks.
166
+ def run_reg(args, timeout: PER_COMMAND_TIMEOUT_SECONDS)
167
+ reg = resolve_reg_exe
168
+ return nil unless reg
169
+ return nil if timeout.to_f <= 0
170
+
171
+ run_bounded([reg] + Array(args).map(&:to_s), timeout)
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ def resolve_reg_exe
177
+ REG_DIRS.each do |dir|
178
+ candidate = File.join(dir, "reg.exe")
179
+ return candidate if File.file?(candidate)
180
+ end
181
+ nil
182
+ rescue StandardError
183
+ nil
184
+ end
185
+
186
+ # Executes the fixed argv and returns combined stdout/stderr, draining BOTH
187
+ # streams concurrently to EOF so a large uninstall tree cannot block the
188
+ # child on a full pipe. Accumulated memory is bounded at REG_MAX_OUTPUT per
189
+ # stream; beyond that we keep draining (discard) so the child still exits.
190
+ # Returns nil on timeout, non-zero exit, empty output, or spawn failure.
191
+ def run_bounded(full_argv, timeout_seconds)
192
+ out_r, out_w = IO.pipe
193
+ err_r, err_w = IO.pipe
194
+ pid = nil
195
+ readers = []
196
+
197
+ begin
198
+ pid = spawn_process(full_argv, out_w, err_w)
199
+ out_w.close
200
+ err_w.close
201
+ out_w = err_w = nil
202
+
203
+ stdout = +"".b
204
+ stderr = +"".b
205
+ readers = [
206
+ Thread.new { stdout = drain_io(out_r) },
207
+ Thread.new { stderr = drain_io(err_r) }
208
+ ]
209
+
210
+ status = nil
211
+ Timeout.timeout(timeout_seconds) do
212
+ _pid, status = Process.wait2(pid)
213
+ pid = nil
214
+ end
215
+ readers.each(&:join)
216
+
217
+ combined = "#{stdout}\n#{stderr}"
218
+ return nil if combined.strip.empty?
219
+ return nil if status && !status.success?
220
+
221
+ combined
222
+ rescue Timeout::Error, SystemCallError, IOError
223
+ nil
224
+ ensure
225
+ if pid
226
+ reap(pid)
227
+ pid = nil
228
+ end
229
+
230
+ close_io(out_w)
231
+ close_io(err_w)
232
+ close_io(out_r)
233
+ close_io(err_r)
234
+ readers.each { |reader| reader.join(0.1) }
235
+ end
236
+ end
237
+
238
+ # Reads a stream to EOF in bounded chunks, retaining at most REG_MAX_OUTPUT
239
+ # bytes but continuing to drain the rest so the writer never blocks.
240
+ def drain_io(io)
241
+ out = +"".b
242
+ loop do
243
+ chunk = io.read(REG_READ_CHUNK)
244
+ break if chunk.nil?
245
+ next if out.bytesize >= REG_MAX_OUTPUT
246
+
247
+ out << chunk
248
+ out = out.byteslice(0, REG_MAX_OUTPUT) if out.bytesize > REG_MAX_OUTPUT
249
+ end
250
+ out
251
+ rescue IOError, Errno::EIO, Errno::EBADF
252
+ out
253
+ end
254
+
255
+ # Windows spawns in a new process group (`new_pgroup:`), POSIX uses `pgroup:`.
256
+ # The POSIX branch also allows the Windows reader to be integration-tested on
257
+ # Linux against a fixture executable.
258
+ def spawn_process(full_argv, out_w, err_w)
259
+ if windows?
260
+ Process.spawn(*full_argv, in: :close, out: out_w, err: err_w, new_pgroup: true)
261
+ else
262
+ Process.spawn(*full_argv, in: :close, out: out_w, err: err_w, pgroup: true)
263
+ end
264
+ end
265
+
266
+ def reap(pid)
267
+ begin
268
+ Process.kill("KILL", pid)
269
+ rescue StandardError
270
+ nil
271
+ end
272
+
273
+ loop do
274
+ Process.waitpid(pid)
275
+ break
276
+ rescue Errno::ECHILD
277
+ break
278
+ rescue Errno::EINTR
279
+ next
280
+ end
281
+ rescue StandardError
282
+ nil
283
+ end
284
+
285
+ def close_io(io)
286
+ io&.close
287
+ rescue StandardError
288
+ nil
289
+ end
290
+
291
+ def monotonic_now
292
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
293
+ end
294
+ end
295
+ end
296
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "windows_components"
4
+
5
+ module Auditea
6
+ module HostInventory
7
+ # Conservative read-only Windows service discovery. Proves Apache HTTP
8
+ # Server identity from trusted service metadata WITHOUT executing the
9
+ # binary, trusting caller paths, or inferring a version from a directory
10
+ # name. A proven-but-unversioned service is reported with version nil so
11
+ # the server can show "Not reported" rather than hiding a known install.
12
+ #
13
+ # Reads only HKLM\SYSTEM\CurrentControlSet\Services (a single, non-WOW64-
14
+ # redirected view). Reuses WindowsComponents.run_reg so the read remains
15
+ # bounded, timeout-protected, fail-open, and read-only.
16
+ module WindowsServices
17
+ SERVICES_KEY = 'HKLM\SYSTEM\CurrentControlSet\Services'
18
+ # Explicit generic Windows software channel (same as uninstall detection).
19
+ WINDOWS_PACKAGE = { "ecosystem" => "windows" }.freeze
20
+
21
+ module_function
22
+
23
+ # Single deterministic read-only services query.
24
+ def service_queries
25
+ [["query", SERVICES_KEY, "/s"]]
26
+ end
27
+
28
+ def collect(deadline:)
29
+ components = []
30
+ apache_emitted = false
31
+
32
+ service_queries.each do |argv|
33
+ remaining = deadline - WindowsComponents.monotonic_now
34
+ break if remaining <= 0
35
+
36
+ output = WindowsComponents.run_reg(
37
+ argv,
38
+ timeout: [WindowsComponents::PER_COMMAND_TIMEOUT_SECONDS, remaining].min
39
+ )
40
+ next if output.nil?
41
+
42
+ parse_services(output).each do |(display_name, image_path)|
43
+ next unless apache_http_server_service?(display_name, image_path)
44
+ next if apache_emitted
45
+
46
+ apache_emitted = true
47
+ components << Inventory::Components.build_without_version(
48
+ product: "Apache HTTP Server",
49
+ scope: "reverse_proxy",
50
+ observation_method: "windows.service_registry",
51
+ package: WINDOWS_PACKAGE,
52
+ evidence: { "command" => "reg.exe" }
53
+ )
54
+ end
55
+ end
56
+
57
+ components
58
+ end
59
+
60
+ # Parses `reg query ...\Services /s` output into [display_name, image_path]
61
+ # pairs, one per service subkey. Ignores values we do not need; never
62
+ # returns raw output or paths as evidence.
63
+ def parse_services(output)
64
+ services = []
65
+ display_name = nil
66
+ image_path = nil
67
+ output.to_s.each_line do |line|
68
+ stripped = line.strip
69
+ if stripped.start_with?("HKEY_")
70
+ services << [display_name, image_path] if display_name || image_path
71
+ display_name = nil
72
+ image_path = nil
73
+ next
74
+ end
75
+
76
+ if (match = stripped.match(/\ADisplayName\s+REG_\S+\s+(.+)\z/i))
77
+ display_name = match[1].to_s.strip
78
+ elsif (match = stripped.match(/\AImagePath\s+REG_\S+\s+(.+)\z/i))
79
+ image_path = match[1].to_s.strip
80
+ end
81
+ end
82
+ services << [display_name, image_path] if display_name || image_path
83
+ services
84
+ end
85
+
86
+ # Proves Apache HTTP Server identity from trusted service metadata. An
87
+ # `httpd.exe` ImagePath is the unambiguous Apache HTTP Server binary;
88
+ # a conservative DisplayName match covers the common "Apache2.4" /
89
+ # "Apache HTTP Server" names while rejecting unrelated Apache projects
90
+ # (Tomcat, Cassandra, DS, ...).
91
+ def apache_http_server_service?(display_name, image_path)
92
+ path = image_path.to_s.strip
93
+ return true if path.match?(%r{(?:\A|[\\/])httpd\.exe(?:["'\s]|\z)}i)
94
+
95
+ name = display_name.to_s.strip
96
+ return false if name.empty?
97
+ return false if name.match?(/\btomcat\b/i)
98
+
99
+ name.match?(/\Aapache(?:\s+http\s+server(?:\s+\d+(?:\.\d+)*)?|\s*\d+(?:\.\d+)*)?\s*\z/i)
100
+ end
101
+ end
102
+ end
103
+ end
@@ -41,6 +41,26 @@ module Auditea
41
41
  entry
42
42
  end
43
43
 
44
+ # A component whose identity is proven but whose version is not available
45
+ # (e.g. an Apache HTTP Server proven as a Windows service, where no
46
+ # authoritative version metadata exists). Emits the component WITHOUT a
47
+ # `version` key so the server can truthfully render "Not reported" rather
48
+ # than inventing or silently dropping the known installed product.
49
+ def build_without_version(product:, scope:, observation_method:, package: nil, evidence: nil)
50
+ return nil unless SCOPES.include?(scope.to_s)
51
+ return nil if blank?(product)
52
+
53
+ entry = {
54
+ "product" => truncate(product, MAX_PRODUCT),
55
+ "scope" => scope.to_s,
56
+ "observation_method" => truncate(observation_method, MAX_METHOD)
57
+ }
58
+ entry["package"] = package if package.is_a?(Hash) && !package.empty?
59
+ sanitized = sanitize_evidence(evidence)
60
+ entry["evidence"] = sanitized if sanitized
61
+ entry
62
+ end
63
+
44
64
  def sanitize_evidence(value)
45
65
  return nil unless value.is_a?(Hash)
46
66
 
@@ -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
@@ -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 { { size: @buffer.size, dropped: @dropped, inflight: @inflight, pid: @owner_pid } }
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 data.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Auditea
4
- VERSION = "0.1.0.beta.4"
4
+ VERSION = "0.1.0.beta.6"
5
5
  end
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
4
+ version: 0.1.0.beta.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - AudiTea
@@ -24,17 +24,22 @@ 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
42
+ - lib/auditea/host_inventory/windows_services.rb
38
43
  - lib/auditea/instrumentation/active_job.rb
39
44
  - lib/auditea/instrumentation/error_subscriber.rb
40
45
  - lib/auditea/instrumentation/exception.rb