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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5459c8f22551f101e1aa2f32689bf47623c3ec2890f448ff5dd92c4980ee8c91
4
- data.tar.gz: 2fb620c50bc457db6f85df2d1745b1c761e3141571a14451caef9080db109e7c
3
+ metadata.gz: 8ed0a68e5bfed09c0f764146e65c9b995d11c7070224a19fc6e682367626d0d1
4
+ data.tar.gz: cfdc642f25cab8125b3c8479b9aacbbaa13613650fa12a9b8160e21d92c2881f
5
5
  SHA512:
6
- metadata.gz: faf9f0876938d536fe4aad106bba8b13ee72031ecb78c14d259a9a313eb046c84601b2b0ec3af9cb510d0b9581c7eb142aea169170f364e813b79ef7ae65f32d
7
- data.tar.gz: df66f3cab946bb98383c903d550f4f7cf842386e4fea0b2fb11b6d037b4c0a2beee1d652c3381199e9c35a30c743a17039844b68c78494429fc64d04448fbc96
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.4 (unreleased)
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.4** (not published yet)
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.4"
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.4
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.4
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.4.gem
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.4 --pre
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.4
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."
@@ -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,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
@@ -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.5"
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.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