auditea 0.1.0.beta.1 → 0.1.0.beta.3

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.
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Auditea
7
+ module Inventory
8
+ # Stable digest over normalized inventory (no timestamps / PIDs / hostnames).
9
+ module DigestComputer
10
+ module_function
11
+
12
+ def compute(inventory)
13
+ payload = canonicalize(inventory_without_digest(inventory))
14
+ Digest::SHA256.hexdigest(JSON.generate(payload))
15
+ end
16
+
17
+ def inventory_without_digest(inventory)
18
+ case inventory
19
+ when Hash
20
+ inventory.each_with_object({}) do |(key, value), memo|
21
+ next if key.to_s == "digest"
22
+
23
+ memo[key.to_s] = inventory_without_digest(value)
24
+ end
25
+ when Array
26
+ inventory.map { |value| inventory_without_digest(value) }
27
+ else
28
+ inventory
29
+ end
30
+ end
31
+
32
+ def canonicalize(value)
33
+ case value
34
+ when Hash
35
+ value.keys.map(&:to_s).sort.to_h do |key|
36
+ [key, canonicalize(hash_fetch(value, key))]
37
+ end
38
+ when Array
39
+ value.map { |child| canonicalize(child) }
40
+ when String, Numeric, TrueClass, FalseClass, NilClass
41
+ value
42
+ else
43
+ value.to_s
44
+ end
45
+ end
46
+
47
+ # Prefer key existence over truthiness so false/nil are preserved.
48
+ def hash_fetch(hash, key)
49
+ return hash[key] if hash.key?(key)
50
+
51
+ sym = key.to_sym
52
+ return hash[sym] if hash.key?(sym)
53
+
54
+ nil
55
+ end
56
+ private_class_method :hash_fetch
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # Process-local emission gate: ~daily refresh, immediate emit on digest change.
6
+ module Emitter
7
+ ACTION = "runtime.inventory"
8
+ CATEGORY = "security"
9
+
10
+ module_function
11
+
12
+ def capture!(force: true)
13
+ return skipped("disabled") unless Auditea.configuration.capture_runtime_inventory?
14
+
15
+ return skipped("not_due") if !force && !collection_due?
16
+
17
+ mark_collection_attempt!
18
+ inventory = Collector.collect
19
+ emit(inventory, force: force)
20
+ rescue StandardError => error
21
+ Auditea.handle_error(error)
22
+ end
23
+
24
+ def maybe_capture!
25
+ capture!(force: false)
26
+ end
27
+
28
+ # Cheap gate for request/lifecycle hooks — does not collect until due.
29
+ def collection_due?(now: monotonic_now)
30
+ state_mutex.synchronize do
31
+ return true if @last_collection_attempt_at.nil?
32
+
33
+ (now - @last_collection_attempt_at) >= Auditea.configuration.inventory_refresh_interval
34
+ end
35
+ end
36
+
37
+ def reset!
38
+ state_mutex.synchronize do
39
+ @last_digest = nil
40
+ @last_emitted_at = nil
41
+ @last_collection_attempt_at = nil
42
+ end
43
+ end
44
+
45
+ def emit(inventory, force:)
46
+ digest = inventory["digest"].to_s
47
+ unless force || should_emit?(digest)
48
+ return { ok: true, skipped: true, reason: "unchanged_within_interval", digest: digest }
49
+ end
50
+
51
+ result = Auditea.capture(
52
+ ACTION,
53
+ category: CATEGORY,
54
+ evidence: { "inventory" => inventory },
55
+ metadata: { "sdk_event_kind" => force ? "runtime_inventory_explicit" : "runtime_inventory_auto" }
56
+ )
57
+
58
+ mark_emitted!(digest) if result.is_a?(Hash) && (result[:ok] || result["ok"])
59
+ result.is_a?(Hash) ? result.merge(digest: digest) : result
60
+ end
61
+ private_class_method :emit
62
+
63
+ def should_emit?(digest)
64
+ state_mutex.synchronize do
65
+ return true if @last_digest.nil?
66
+ return true if digest != @last_digest
67
+ return true if @last_emitted_at.nil?
68
+
69
+ (monotonic_now - @last_emitted_at) >= Auditea.configuration.inventory_refresh_interval
70
+ end
71
+ end
72
+ private_class_method :should_emit?
73
+
74
+ def mark_emitted!(digest)
75
+ state_mutex.synchronize do
76
+ @last_digest = digest
77
+ @last_emitted_at = monotonic_now
78
+ end
79
+ end
80
+ private_class_method :mark_emitted!
81
+
82
+ def mark_collection_attempt!
83
+ state_mutex.synchronize do
84
+ @last_collection_attempt_at = monotonic_now
85
+ end
86
+ end
87
+ private_class_method :mark_collection_attempt!
88
+
89
+ def skipped(reason)
90
+ { ok: false, skipped: true, reason: reason }
91
+ end
92
+ private_class_method :skipped
93
+
94
+ def state_mutex
95
+ @state_mutex ||= Mutex.new
96
+ end
97
+ private_class_method :state_mutex
98
+
99
+ def monotonic_now
100
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
101
+ end
102
+ private_class_method :monotonic_now
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Auditea
4
+ module Inventory
5
+ # UTF-8-safe byte bounds for inventory string fields.
6
+ module StringBound
7
+ ELLIPSIS = "…"
8
+ ELLIPSIS_BYTES = ELLIPSIS.bytesize
9
+
10
+ module_function
11
+
12
+ # Returns a string with bytesize <= max_bytes (valid UTF-8), or nil for nil input.
13
+ # When truncating, reserves ELLIPSIS_BYTES from the budget when max_bytes allows;
14
+ # otherwise truncates without an ellipsis. max_bytes <= 0 yields "".
15
+ def truncate(value, max_bytes)
16
+ return nil if value.nil?
17
+ return "" if max_bytes <= 0
18
+
19
+ string = value.to_s
20
+ return string if string.bytesize <= max_bytes
21
+
22
+ if max_bytes < ELLIPSIS_BYTES
23
+ truncate_bytes(string, max_bytes)
24
+ else
25
+ "#{truncate_bytes(string, max_bytes - ELLIPSIS_BYTES)}#{ELLIPSIS}"
26
+ end
27
+ end
28
+
29
+ def truncate_bytes(string, budget)
30
+ return "" if budget <= 0
31
+
32
+ truncated = string.b.byteslice(0, budget)
33
+ truncated.force_encoding(Encoding::UTF_8)
34
+ until truncated.empty? || truncated.valid_encoding?
35
+ truncated = truncated.byteslice(0, truncated.bytesize - 1)
36
+ truncated.force_encoding(Encoding::UTF_8)
37
+ end
38
+ truncated
39
+ end
40
+ private_class_method :truncate_bytes
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inventory/string_bound"
4
+ require_relative "inventory/digest"
5
+ require_relative "inventory/collector"
6
+ require_relative "inventory/emitter"
7
+ require_relative "inventory/background_refresh"
8
+
9
+ module Auditea
10
+ # Runtime and dependency inventory for AudiTea (opt-in).
11
+ module Inventory
12
+ module_function
13
+
14
+ def capture!(force: true)
15
+ Emitter.capture!(force: force)
16
+ end
17
+
18
+ def maybe_capture!
19
+ Emitter.maybe_capture!
20
+ end
21
+
22
+ # Cheap request-path entry: schedule background capture when due (never inline).
23
+ def schedule_refresh!
24
+ BackgroundRefresh.schedule!
25
+ end
26
+
27
+ def collection_due?
28
+ Emitter.collection_due?
29
+ end
30
+
31
+ def reset!
32
+ Emitter.reset!
33
+ BackgroundRefresh.reset!
34
+ end
35
+ end
36
+ end
@@ -4,6 +4,7 @@ module Auditea
4
4
  module Middleware
5
5
  # Rack middleware: sets CurrentContext with safe structural request fields.
6
6
  # Does NOT store session_id, cookies, Authorization, or query strings.
7
+ # Opportunistic inventory refresh uses a cheap due-gate only; collection is rare.
7
8
  class Context
8
9
  def initialize(app)
9
10
  @app = app
@@ -11,6 +12,7 @@ module Auditea
11
12
 
12
13
  def call(env)
13
14
  CurrentContext.clear!
15
+ Instrumentation::Exception::Deduper.clear!
14
16
  request = ::Rack::Request.new(env)
15
17
  CurrentContext.set(
16
18
  "request_id" => env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
@@ -21,7 +23,9 @@ module Auditea
21
23
  )
22
24
  @app.call(env)
23
25
  ensure
26
+ opportunistically_refresh_inventory
24
27
  CurrentContext.clear!
28
+ Instrumentation::Exception::Deduper.clear!
25
29
  end
26
30
 
27
31
  private
@@ -30,6 +34,29 @@ module Auditea
30
34
  path = request.path.to_s
31
35
  path.split("?", 2).first
32
36
  end
37
+
38
+ def opportunistically_refresh_inventory
39
+ refresh_runtime_inventory
40
+ refresh_host_inventory
41
+ rescue StandardError => error
42
+ Auditea.handle_error(error)
43
+ end
44
+
45
+ def refresh_runtime_inventory
46
+ return unless Auditea.configuration.capture_runtime_inventory?
47
+ return unless Auditea::Inventory.collection_due?
48
+
49
+ # Never collect/deliver inline on the request path (even with sync transport).
50
+ Auditea::Inventory.schedule_refresh!
51
+ end
52
+
53
+ def refresh_host_inventory
54
+ return unless Auditea.configuration.capture_host_inventory?
55
+ return unless Auditea::HostInventory.collection_due?
56
+
57
+ # Never collect/deliver inline on the request path (even with sync transport).
58
+ Auditea::HostInventory.schedule_refresh!
59
+ end
33
60
  end
34
61
  end
35
62
  end
@@ -18,10 +18,18 @@ module Auditea
18
18
  initializer "auditea.instrumentation" do
19
19
  Auditea::Instrumentation::Request.install!
20
20
  Auditea::Instrumentation::ActiveJob.install!
21
+ Auditea::Instrumentation::ErrorSubscriber.install!
21
22
  end
22
23
 
23
24
  config.after_initialize do
24
25
  at_exit { Auditea.shutdown }
26
+ begin
27
+ # Startup capture is explicit (force) so long-lived refresh can stay gated.
28
+ Auditea.capture_runtime_inventory! if Auditea.configuration.capture_runtime_inventory?
29
+ Auditea.capture_host_inventory! if Auditea.configuration.capture_host_inventory?
30
+ rescue StandardError => error
31
+ Auditea.handle_error(error)
32
+ end
25
33
  end
26
34
  end
27
35
  end
@@ -6,6 +6,10 @@ module Auditea
6
6
  # Exact normalized key matching only (no substring matches).
7
7
  module Sanitizer
8
8
  FILTERED = "[FILTERED]"
9
+ DEFAULT_ARRAY_MAX = 100
10
+ # Inventory packages are intentionally larger than ordinary evidence arrays.
11
+ # Keep in sync with Inventory::Collector::MAX_PACKAGES (literal avoids load-order coupling).
12
+ INVENTORY_PACKAGES_ARRAY_MAX = 400
9
13
  SENSITIVE_KEYS = %w[
10
14
  password password_confirmation password_digest authorization cookie set_cookie
11
15
  api_key access_token refresh_token secret secret_key client_secret private_key token
@@ -42,6 +46,14 @@ module Auditea
42
46
  "#{string.byteslice(0, max_bytes)}…"
43
47
  end
44
48
 
49
+ def inventory_packages_path?(path)
50
+ path.to_s == "evidence.inventory.packages"
51
+ end
52
+
53
+ def array_limit_for(path)
54
+ inventory_packages_path?(path) ? INVENTORY_PACKAGES_ARRAY_MAX : DEFAULT_ARRAY_MAX
55
+ end
56
+
45
57
  def deep_sanitize(value, depth:, path:, redacted:)
46
58
  cfg = Auditea.configuration
47
59
  return { "truncated" => true, "reason" => "max_depth" } if depth > cfg.nesting_max_depth
@@ -59,7 +71,8 @@ module Auditea
59
71
  end
60
72
  end
61
73
  when Array
62
- value.first(100).each_with_index.map do |child, index|
74
+ limit = array_limit_for(path)
75
+ value.first(limit).each_with_index.map do |child, index|
63
76
  child_path = path ? "#{path}[#{index}]" : "[#{index}]"
64
77
  deep_sanitize(child, depth: depth + 1, path: child_path, redacted: redacted)
65
78
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Auditea
4
- VERSION = "0.1.0.beta.1"
4
+ VERSION = "0.1.0.beta.3"
5
5
  end
data/lib/auditea.rb CHANGED
@@ -28,8 +28,12 @@ require_relative "auditea/transport/http"
28
28
  require_relative "auditea/transport/queue"
29
29
  require_relative "auditea/client"
30
30
  require_relative "auditea/log"
31
+ require_relative "auditea/inventory"
32
+ require_relative "auditea/host_inventory"
31
33
  require_relative "auditea/instrumentation/request"
32
34
  require_relative "auditea/instrumentation/exception"
35
+ require_relative "auditea/instrumentation/structural_request"
36
+ require_relative "auditea/instrumentation/error_subscriber"
33
37
  require_relative "auditea/instrumentation/logger_adapter"
34
38
  require_relative "auditea/instrumentation/active_job"
35
39
  require_relative "auditea/middleware/context"
@@ -54,6 +58,8 @@ module Auditea
54
58
  @configuration = Configuration.new
55
59
  @client&.shutdown
56
60
  @client = nil
61
+ Inventory.reset!
62
+ HostInventory.reset!
57
63
  end
58
64
 
59
65
  def client
@@ -90,6 +96,25 @@ module Auditea
90
96
  # Alias kept for legacy familiarity (KEEP CONCEPT).
91
97
  alias track capture
92
98
 
99
+ # First-class exception capture → action `exception.raised` / category `error`.
100
+ # Rebuilds structured exception evidence (type, message, frames, handled, mechanism).
101
+ # Uses CurrentContext when present; sanitizes like normal events; fail-open by default.
102
+ def capture_exception(error, handled: false, mechanism: nil, severity: "error",
103
+ context: nil, metadata: nil, evidence: nil)
104
+ Instrumentation::Exception.capture(
105
+ error,
106
+ handled: handled,
107
+ mechanism: mechanism || "auditea.capture_exception",
108
+ severity: severity,
109
+ context: context,
110
+ metadata: (metadata || {}).merge("sdk_event_kind" => "explicit"),
111
+ evidence: evidence,
112
+ claim: true
113
+ )
114
+ rescue StandardError => error
115
+ handle_error(error)
116
+ end
117
+
93
118
  # Structured application log as a first-class event (not Rails.logger auto-forward).
94
119
  def log(level, message, context: {}, metadata: {})
95
120
  Log.record(level, message, context: context, metadata: metadata)
@@ -97,6 +122,26 @@ module Auditea
97
122
  handle_error(error)
98
123
  end
99
124
 
125
+ # Opt-in runtime + dependency inventory (`runtime.inventory`). Default off.
126
+ # Does not raise on collection failure unless raise_errors is enabled.
127
+ def capture_runtime_inventory!(force: true)
128
+ Inventory.capture!(force: force)
129
+ end
130
+
131
+ # Bounded refresh for long-lived processes (~daily when unchanged).
132
+ def maybe_capture_runtime_inventory!
133
+ Inventory.maybe_capture!
134
+ end
135
+
136
+ # Opt-in host/platform inventory (`host.inventory`). Default off.
137
+ def capture_host_inventory!(force: true)
138
+ HostInventory.capture!(force: force)
139
+ end
140
+
141
+ def maybe_capture_host_inventory!
142
+ HostInventory.maybe_capture!
143
+ end
144
+
100
145
  def flush(timeout: 5)
101
146
  client.flush(timeout: timeout)
102
147
  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.1
4
+ version: 0.1.0.beta.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - AudiTea
@@ -9,8 +9,8 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: Capture business events, Rails request evidence, exceptions, and structured
13
- logs for AudiTea /api/v1.
12
+ description: Capture business events, Rails request evidence, exceptions, structured
13
+ logs, and optional runtime/host inventory for AudiTea /api/v1.
14
14
  email:
15
15
  - opensource@auditea.app
16
16
  executables: []
@@ -30,10 +30,22 @@ files:
30
30
  - lib/auditea/configuration.rb
31
31
  - lib/auditea/current_context.rb
32
32
  - lib/auditea/event_builder.rb
33
+ - lib/auditea/host_inventory.rb
34
+ - lib/auditea/host_inventory/background_refresh.rb
35
+ - lib/auditea/host_inventory/collector.rb
36
+ - lib/auditea/host_inventory/emitter.rb
33
37
  - lib/auditea/instrumentation/active_job.rb
38
+ - lib/auditea/instrumentation/error_subscriber.rb
34
39
  - lib/auditea/instrumentation/exception.rb
35
40
  - lib/auditea/instrumentation/logger_adapter.rb
36
41
  - lib/auditea/instrumentation/request.rb
42
+ - lib/auditea/instrumentation/structural_request.rb
43
+ - lib/auditea/inventory.rb
44
+ - lib/auditea/inventory/background_refresh.rb
45
+ - lib/auditea/inventory/collector.rb
46
+ - lib/auditea/inventory/digest.rb
47
+ - lib/auditea/inventory/emitter.rb
48
+ - lib/auditea/inventory/string_bound.rb
37
49
  - lib/auditea/log.rb
38
50
  - lib/auditea/middleware/context.rb
39
51
  - lib/auditea/railtie.rb