auditea 0.1.0.beta.2 → 0.1.0.beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +24 -3
- data/RELEASE.md +5 -5
- data/lib/auditea/host_inventory/collector.rb +47 -1
- data/lib/auditea/host_inventory/component_detectors.rb +312 -0
- data/lib/auditea/instrumentation/active_job.rb +3 -0
- data/lib/auditea/instrumentation/error_subscriber.rb +93 -0
- data/lib/auditea/instrumentation/exception.rb +134 -16
- data/lib/auditea/instrumentation/structural_request.rb +136 -0
- data/lib/auditea/inventory/collector.rb +126 -0
- data/lib/auditea/inventory/components.rb +80 -0
- data/lib/auditea/inventory/database_server.rb +57 -0
- data/lib/auditea/middleware/context.rb +2 -0
- data/lib/auditea/railtie.rb +5 -3
- data/lib/auditea/sanitizer.rb +11 -1
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +21 -0
- metadata +6 -1
|
@@ -2,28 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
module Auditea
|
|
4
4
|
module Instrumentation
|
|
5
|
+
# Structured exception → `exception.raised` evidence.
|
|
6
|
+
#
|
|
7
|
+
# Capture paths (fail-open at the Auditea boundary):
|
|
8
|
+
# - explicit Auditea.capture_exception
|
|
9
|
+
# - Rails ErrorReporter subscriber (preferred when available)
|
|
10
|
+
# - process_action.action_controller fallback when ErrorReporter is absent
|
|
5
11
|
module Exception
|
|
12
|
+
MAX_FRAMES = 40
|
|
13
|
+
MESSAGE_MAX = 500
|
|
14
|
+
|
|
6
15
|
module_function
|
|
7
16
|
|
|
17
|
+
def capture(error, handled: false, mechanism: nil, severity: "error",
|
|
18
|
+
context: nil, metadata: nil, evidence: nil, claim: true)
|
|
19
|
+
return { ok: false, skipped: true, reason: "disabled" } unless Auditea.configuration.capture_exceptions
|
|
20
|
+
return { ok: false, skipped: true, reason: "not_an_exception" } unless error.is_a?(::Exception)
|
|
21
|
+
return { ok: false, skipped: true, reason: "duplicate" } if claim && !Deduper.claim!(error)
|
|
22
|
+
|
|
23
|
+
merged_evidence = merge_evidence(error, handled: handled, mechanism: mechanism, evidence: evidence)
|
|
24
|
+
meta = { "sdk_event_kind" => "auto_observed" }
|
|
25
|
+
meta.merge!(stringify_keys(metadata)) if metadata
|
|
26
|
+
|
|
27
|
+
Auditea.capture(
|
|
28
|
+
"exception.raised",
|
|
29
|
+
category: "error",
|
|
30
|
+
severity: severity || "error",
|
|
31
|
+
evidence: merged_evidence,
|
|
32
|
+
context: context,
|
|
33
|
+
metadata: meta
|
|
34
|
+
)
|
|
35
|
+
rescue StandardError => error
|
|
36
|
+
Auditea.handle_error(error)
|
|
37
|
+
end
|
|
38
|
+
|
|
8
39
|
def capture_from_notification(event)
|
|
40
|
+
# Prefer Rails ErrorReporter when installed so handled/source semantics stay
|
|
41
|
+
# consistent and ActionDispatch does not double-emit with process_action.
|
|
42
|
+
return if ErrorSubscriber.installed?
|
|
43
|
+
|
|
9
44
|
payload = event.payload || {}
|
|
10
45
|
exception = payload[:exception_object]
|
|
11
46
|
exception ||= build_from_array(payload[:exception])
|
|
12
47
|
return unless exception
|
|
13
48
|
|
|
14
|
-
frames = safe_frames(exception)
|
|
15
49
|
path = payload[:path].to_s.split("?", 2).first
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
severity: "error",
|
|
50
|
+
capture(
|
|
51
|
+
exception,
|
|
52
|
+
handled: false,
|
|
53
|
+
mechanism: "action_controller.process_action",
|
|
21
54
|
evidence: {
|
|
22
|
-
"exception" => {
|
|
23
|
-
"type" => exception.class.name,
|
|
24
|
-
"message" => Sanitizer.truncate_string(exception.message.to_s, 500),
|
|
25
|
-
"frames" => frames
|
|
26
|
-
}.compact,
|
|
27
55
|
"http" => {
|
|
28
56
|
"method" => payload[:method],
|
|
29
57
|
"path" => path,
|
|
@@ -33,15 +61,46 @@ module Auditea
|
|
|
33
61
|
"controller" => payload[:controller],
|
|
34
62
|
"action" => payload[:action]
|
|
35
63
|
}.compact
|
|
36
|
-
},
|
|
37
|
-
metadata: {
|
|
38
|
-
"sdk_event_kind" => "auto_observed"
|
|
39
64
|
}
|
|
40
65
|
)
|
|
41
66
|
rescue StandardError => error
|
|
42
67
|
Auditea.handle_error(error)
|
|
43
68
|
end
|
|
44
69
|
|
|
70
|
+
def merge_evidence(error, handled:, mechanism:, evidence:)
|
|
71
|
+
base = stringify_keys(evidence || {})
|
|
72
|
+
exception = base["exception"].is_a?(Hash) ? base["exception"].dup : {}
|
|
73
|
+
exception["type"] ||= error.class.name
|
|
74
|
+
exception["message"] ||= Sanitizer.truncate_string(error.message.to_s, MESSAGE_MAX)
|
|
75
|
+
exception["frames"] ||= safe_frames(error)
|
|
76
|
+
exception["handled"] = handled ? true : false unless exception.key?("handled")
|
|
77
|
+
exception["mechanism"] = mechanism.to_s if mechanism && !exception.key?("mechanism")
|
|
78
|
+
|
|
79
|
+
http = base["http"].is_a?(Hash) ? base["http"].dup : {}
|
|
80
|
+
enrich_http_from_context!(http)
|
|
81
|
+
|
|
82
|
+
rails = base["rails"].is_a?(Hash) ? base["rails"].dup : {}
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
"exception" => exception.compact,
|
|
86
|
+
"http" => compact_hash(http),
|
|
87
|
+
"rails" => compact_hash(rails)
|
|
88
|
+
}.compact.merge(base.except("exception", "http", "rails"))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def compact_hash(hash)
|
|
92
|
+
cleaned = hash.compact
|
|
93
|
+
cleaned.empty? ? nil : cleaned
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def enrich_http_from_context!(http)
|
|
97
|
+
ctx = CurrentContext.to_h
|
|
98
|
+
http_ctx = ctx["http"].is_a?(Hash) ? ctx["http"] : {}
|
|
99
|
+
http["method"] ||= http_ctx["method"]
|
|
100
|
+
path = http_ctx["path"].to_s.split("?", 2).first
|
|
101
|
+
http["path"] ||= path unless path.nil? || path.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
45
104
|
def build_from_array(pair)
|
|
46
105
|
return nil unless pair.is_a?(Array) && pair.size >= 2
|
|
47
106
|
|
|
@@ -56,9 +115,12 @@ module Auditea
|
|
|
56
115
|
def safe_frames(exception)
|
|
57
116
|
return [] unless exception.respond_to?(:backtrace) && exception.backtrace
|
|
58
117
|
|
|
59
|
-
exception.backtrace.first(
|
|
118
|
+
exception.backtrace.first(MAX_FRAMES).map do |line|
|
|
60
119
|
file, linenum, method_name = parse_frame(line)
|
|
61
|
-
{ "file" => file, "line" => linenum, "method" => method_name }.compact
|
|
120
|
+
frame = { "file" => file, "line" => linenum, "method" => method_name }.compact
|
|
121
|
+
in_app = classify_in_app(file)
|
|
122
|
+
frame["in_app"] = in_app unless in_app.nil?
|
|
123
|
+
frame
|
|
62
124
|
end
|
|
63
125
|
end
|
|
64
126
|
|
|
@@ -70,6 +132,62 @@ module Auditea
|
|
|
70
132
|
[line, nil, nil]
|
|
71
133
|
end
|
|
72
134
|
end
|
|
135
|
+
|
|
136
|
+
# Deterministic app-vs-framework marking. Returns nil when unknown (omit key).
|
|
137
|
+
def classify_in_app(file)
|
|
138
|
+
path = file.to_s
|
|
139
|
+
return nil if path.empty?
|
|
140
|
+
|
|
141
|
+
return true if path.start_with?("app/") || path.include?("/app/")
|
|
142
|
+
return false if path.include?("/gems/") || path.include?("/ruby/") ||
|
|
143
|
+
path.include?("/vendor/bundle/") || path.include?("/lib/ruby/")
|
|
144
|
+
|
|
145
|
+
if defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
|
|
146
|
+
root = ::Rails.root.to_s
|
|
147
|
+
if path.start_with?(root)
|
|
148
|
+
return false if path.start_with?("#{root}/vendor")
|
|
149
|
+
|
|
150
|
+
return true
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def stringify_keys(value)
|
|
158
|
+
EventBuilder.stringify_keys(value)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Identity-safe claim set: WeakMap keys are the exception objects themselves.
|
|
162
|
+
# GC removes entries when exceptions are collected, so recycled object_ids cannot
|
|
163
|
+
# falsely suppress a distinct raise. Still cleared at request/job boundaries.
|
|
164
|
+
module Deduper
|
|
165
|
+
class << self
|
|
166
|
+
def claim!(error)
|
|
167
|
+
map = store
|
|
168
|
+
return false if map.key?(error)
|
|
169
|
+
|
|
170
|
+
map[error] = true
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def claimed?(error)
|
|
175
|
+
store.key?(error)
|
|
176
|
+
rescue StandardError
|
|
177
|
+
false
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def clear!
|
|
181
|
+
Thread.current[:auditea_exception_claims] = ObjectSpace::WeakMap.new
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
def store
|
|
187
|
+
Thread.current[:auditea_exception_claims] ||= ObjectSpace::WeakMap.new
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
73
191
|
end
|
|
74
192
|
end
|
|
75
193
|
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Instrumentation
|
|
5
|
+
# Derive scalar request/controller fields from Rails ErrorReporter context.
|
|
6
|
+
# Never retains controller/request objects; never reads params/body/cookies/session.
|
|
7
|
+
module StructuralRequest
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def from_reporter_context(context)
|
|
11
|
+
hash = context.is_a?(Hash) ? context : {}
|
|
12
|
+
controller = hash[:controller] || hash["controller"]
|
|
13
|
+
|
|
14
|
+
rails = {}
|
|
15
|
+
http = {}
|
|
16
|
+
event_context = {}
|
|
17
|
+
|
|
18
|
+
if controller && !controller.is_a?(String) && controller.respond_to?(:class)
|
|
19
|
+
rails["controller"] = controller.class.name
|
|
20
|
+
if controller.respond_to?(:action_name)
|
|
21
|
+
action = controller.action_name
|
|
22
|
+
rails["action"] = action.to_s if present?(action)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
request = safe_request(controller)
|
|
26
|
+
if request
|
|
27
|
+
method = safe_request_method(request)
|
|
28
|
+
http["method"] = method if present?(method)
|
|
29
|
+
|
|
30
|
+
path = safe_path(request)
|
|
31
|
+
http["path"] = path if present?(path)
|
|
32
|
+
|
|
33
|
+
request_id = safe_request_id(request)
|
|
34
|
+
event_context["request_id"] = request_id if present?(request_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
status = safe_status(controller)
|
|
38
|
+
http["status"] = status unless status.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
"evidence" => {
|
|
43
|
+
"rails" => compact_hash(rails),
|
|
44
|
+
"http" => compact_hash(http)
|
|
45
|
+
}.compact,
|
|
46
|
+
"context" => compact_hash(event_context)
|
|
47
|
+
}
|
|
48
|
+
rescue StandardError
|
|
49
|
+
{ "evidence" => {}, "context" => {} }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def safe_request(controller)
|
|
53
|
+
return nil unless controller.respond_to?(:request)
|
|
54
|
+
|
|
55
|
+
request = controller.request
|
|
56
|
+
return nil if request.nil?
|
|
57
|
+
return nil unless request.respond_to?(:request_method) || request.respond_to?(:path)
|
|
58
|
+
|
|
59
|
+
request
|
|
60
|
+
rescue StandardError
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def safe_request_method(request)
|
|
65
|
+
return request.request_method.to_s if request.respond_to?(:request_method)
|
|
66
|
+
|
|
67
|
+
nil
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def safe_path(request)
|
|
73
|
+
raw =
|
|
74
|
+
if request.respond_to?(:path)
|
|
75
|
+
request.path
|
|
76
|
+
elsif request.respond_to?(:fullpath)
|
|
77
|
+
request.fullpath
|
|
78
|
+
end
|
|
79
|
+
return nil unless present?(raw)
|
|
80
|
+
|
|
81
|
+
raw.to_s.split("?", 2).first
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def safe_request_id(request)
|
|
87
|
+
if request.respond_to?(:request_id)
|
|
88
|
+
value = request.request_id
|
|
89
|
+
return value.to_s if present?(value)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if request.respond_to?(:env)
|
|
93
|
+
env = request.env
|
|
94
|
+
if env.is_a?(Hash)
|
|
95
|
+
value = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
|
|
96
|
+
return value.to_s if present?(value)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
nil
|
|
101
|
+
rescue StandardError
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def safe_status(controller)
|
|
106
|
+
return nil unless controller.respond_to?(:response)
|
|
107
|
+
|
|
108
|
+
response = controller.response
|
|
109
|
+
return nil unless response
|
|
110
|
+
return nil unless response.respond_to?(:status)
|
|
111
|
+
|
|
112
|
+
status = response.status
|
|
113
|
+
return nil unless status.is_a?(Integer)
|
|
114
|
+
return nil unless status.between?(100, 599)
|
|
115
|
+
|
|
116
|
+
# Unhandled failures often still have the default 200 before rendering;
|
|
117
|
+
# only keep status when the response looks finalized or already erroneous.
|
|
118
|
+
committed = response.respond_to?(:committed?) && response.committed?
|
|
119
|
+
return status if committed || status >= 400
|
|
120
|
+
|
|
121
|
+
nil
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def compact_hash(hash)
|
|
127
|
+
cleaned = hash.compact
|
|
128
|
+
cleaned.empty? ? nil : cleaned
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def present?(value)
|
|
132
|
+
!(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
require "digest"
|
|
4
4
|
require "json"
|
|
5
5
|
require_relative "string_bound"
|
|
6
|
+
require_relative "components"
|
|
7
|
+
require_relative "database_server"
|
|
6
8
|
|
|
7
9
|
module Auditea
|
|
8
10
|
module Inventory
|
|
9
11
|
# Collects a normalized runtime + dependency inventory for AudiTea.
|
|
10
12
|
# Never includes filesystem paths, remote URLs, tokens, or env vars.
|
|
13
|
+
# rubocop:disable-next Metrics/ModuleLength -- cohesive collector + component promotion
|
|
11
14
|
module Collector
|
|
12
15
|
SCHEMA_VERSION = 1
|
|
13
16
|
MAX_PACKAGES = 400
|
|
@@ -15,6 +18,16 @@ module Auditea
|
|
|
15
18
|
MAX_REQUIREMENT_BYTES = 256
|
|
16
19
|
# Leave headroom under Transport::Limits::SINGLE_MAX_BYTES after envelope/provenance.
|
|
17
20
|
MAX_INVENTORY_BYTES = 110_000
|
|
21
|
+
PROMOTED_SERVER_GEMS = {
|
|
22
|
+
"puma" => { "product" => "Puma", "scope" => "application_server" },
|
|
23
|
+
"unicorn" => { "product" => "Unicorn", "scope" => "application_server" },
|
|
24
|
+
"passenger" => { "product" => "Phusion Passenger", "scope" => "application_server" }
|
|
25
|
+
}.freeze
|
|
26
|
+
PROMOTED_ADAPTER_GEMS = {
|
|
27
|
+
"pg" => { "product" => "pg (PostgreSQL adapter)", "scope" => "linked_library" },
|
|
28
|
+
"trilogy" => { "product" => "Trilogy", "scope" => "linked_library" },
|
|
29
|
+
"mysql2" => { "product" => "mysql2", "scope" => "linked_library" }
|
|
30
|
+
}.freeze
|
|
18
31
|
|
|
19
32
|
module_function
|
|
20
33
|
|
|
@@ -41,11 +54,124 @@ module Auditea
|
|
|
41
54
|
release = Auditea.configuration.release
|
|
42
55
|
inventory["application"] = { "release" => bound_string(release) } if present?(release)
|
|
43
56
|
|
|
57
|
+
components = Components.compact_list(
|
|
58
|
+
runtime_components(inventory) +
|
|
59
|
+
promoted_gem_components(packages) +
|
|
60
|
+
[DatabaseServer.collect]
|
|
61
|
+
)
|
|
62
|
+
inventory["components"] = components unless components.empty?
|
|
63
|
+
|
|
44
64
|
inventory = enforce_size_bound(inventory)
|
|
45
65
|
inventory["digest"] = DigestComputer.compute(inventory)
|
|
46
66
|
inventory
|
|
47
67
|
end
|
|
48
68
|
|
|
69
|
+
def runtime_components(inventory)
|
|
70
|
+
list = []
|
|
71
|
+
runtime = inventory["runtime"] || {}
|
|
72
|
+
list << Components.build(
|
|
73
|
+
product: "Ruby",
|
|
74
|
+
version: runtime["version"],
|
|
75
|
+
scope: "application_runtime",
|
|
76
|
+
observation_method: "ruby_constants"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
framework = inventory["framework"]
|
|
80
|
+
if framework
|
|
81
|
+
list << Components.build(
|
|
82
|
+
product: "Rails",
|
|
83
|
+
version: framework["version"],
|
|
84
|
+
scope: "application_runtime",
|
|
85
|
+
observation_method: "rails_version"
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
pm = inventory["package_manager"] || {}
|
|
90
|
+
if present?(pm["rubygems_version"])
|
|
91
|
+
list << Components.build(
|
|
92
|
+
product: "RubyGems",
|
|
93
|
+
version: pm["rubygems_version"],
|
|
94
|
+
scope: "application_runtime",
|
|
95
|
+
observation_method: "rubygems_version"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
if present?(pm["bundler_version"])
|
|
99
|
+
list << Components.build(
|
|
100
|
+
product: "Bundler",
|
|
101
|
+
version: pm["bundler_version"],
|
|
102
|
+
scope: "application_runtime",
|
|
103
|
+
observation_method: "bundler_version"
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
openssl = inventory["openssl"] || {}
|
|
108
|
+
openssl_version = first_present(
|
|
109
|
+
openssl["library_version"],
|
|
110
|
+
openssl["build_version"],
|
|
111
|
+
openssl["extension_version"]
|
|
112
|
+
)
|
|
113
|
+
if present?(openssl_version)
|
|
114
|
+
list << Components.build(
|
|
115
|
+
product: "OpenSSL",
|
|
116
|
+
version: openssl_version,
|
|
117
|
+
scope: "linked_library",
|
|
118
|
+
observation_method: "ruby_openssl_constants",
|
|
119
|
+
evidence: {
|
|
120
|
+
"extension_version" => openssl["extension_version"],
|
|
121
|
+
"build_version" => openssl["build_version"],
|
|
122
|
+
"library_version" => openssl["library_version"]
|
|
123
|
+
}.compact
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
list
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def promoted_gem_components(packages)
|
|
131
|
+
by_name = Array(packages).to_h { |pkg| [pkg["name"].to_s, pkg] }
|
|
132
|
+
list = []
|
|
133
|
+
|
|
134
|
+
PROMOTED_SERVER_GEMS.each do |gem_name, meta|
|
|
135
|
+
pkg = by_name[gem_name]
|
|
136
|
+
next unless pkg
|
|
137
|
+
|
|
138
|
+
list << Components.build(
|
|
139
|
+
product: meta["product"],
|
|
140
|
+
version: pkg["version"],
|
|
141
|
+
scope: meta["scope"],
|
|
142
|
+
observation_method: "bundler_package",
|
|
143
|
+
package: {
|
|
144
|
+
"ecosystem" => pkg["ecosystem"],
|
|
145
|
+
"name" => pkg["name"],
|
|
146
|
+
"version" => pkg["version"]
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
PROMOTED_ADAPTER_GEMS.each do |gem_name, meta|
|
|
152
|
+
pkg = by_name[gem_name]
|
|
153
|
+
next unless pkg
|
|
154
|
+
|
|
155
|
+
list << Components.build(
|
|
156
|
+
product: meta["product"],
|
|
157
|
+
version: pkg["version"],
|
|
158
|
+
scope: meta["scope"],
|
|
159
|
+
observation_method: "bundler_package",
|
|
160
|
+
package: {
|
|
161
|
+
"ecosystem" => pkg["ecosystem"],
|
|
162
|
+
"name" => pkg["name"],
|
|
163
|
+
"version" => pkg["version"]
|
|
164
|
+
}
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
list
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def first_present(*values)
|
|
172
|
+
values.find { |value| present?(value) }
|
|
173
|
+
end
|
|
174
|
+
|
|
49
175
|
def runtime_metadata
|
|
50
176
|
{
|
|
51
177
|
"language" => "ruby",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Inventory
|
|
5
|
+
# Normalized platform/software component observations (inventory only — not vulnerability).
|
|
6
|
+
module Components
|
|
7
|
+
SCOPES = %w[
|
|
8
|
+
application_runtime
|
|
9
|
+
application_server
|
|
10
|
+
database_server
|
|
11
|
+
linked_library
|
|
12
|
+
host_os
|
|
13
|
+
host_component
|
|
14
|
+
reverse_proxy
|
|
15
|
+
externally_observed
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
MAX_COMPONENTS = 40
|
|
19
|
+
MAX_PRODUCT = 128
|
|
20
|
+
MAX_VERSION = 128
|
|
21
|
+
MAX_METHOD = 64
|
|
22
|
+
MAX_EVIDENCE_VALUE = 64
|
|
23
|
+
# Strict allowlist — never transmit raw command output, paths, or host details.
|
|
24
|
+
EVIDENCE_KEYS = %w[command adapter].freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def build(product:, version:, scope:, observation_method:, package: nil, evidence: nil)
|
|
29
|
+
return nil unless SCOPES.include?(scope.to_s)
|
|
30
|
+
return nil if blank?(product) || blank?(version)
|
|
31
|
+
|
|
32
|
+
entry = {
|
|
33
|
+
"product" => truncate(product, MAX_PRODUCT),
|
|
34
|
+
"version" => truncate(version, MAX_VERSION),
|
|
35
|
+
"scope" => scope.to_s,
|
|
36
|
+
"observation_method" => truncate(observation_method, MAX_METHOD)
|
|
37
|
+
}
|
|
38
|
+
entry["package"] = package if package.is_a?(Hash) && !package.empty?
|
|
39
|
+
sanitized = sanitize_evidence(evidence)
|
|
40
|
+
entry["evidence"] = sanitized if sanitized
|
|
41
|
+
entry
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def sanitize_evidence(value)
|
|
45
|
+
return nil unless value.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
out = {}
|
|
48
|
+
value.each do |key, child|
|
|
49
|
+
key_s = key.to_s
|
|
50
|
+
next unless EVIDENCE_KEYS.include?(key_s)
|
|
51
|
+
next if child.nil?
|
|
52
|
+
|
|
53
|
+
case child
|
|
54
|
+
when String
|
|
55
|
+
next if child.empty?
|
|
56
|
+
|
|
57
|
+
out[key_s] = truncate(child, MAX_EVIDENCE_VALUE)
|
|
58
|
+
when Numeric, TrueClass, FalseClass
|
|
59
|
+
out[key_s] = child
|
|
60
|
+
else
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
out.empty? ? nil : out
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def compact_list(list)
|
|
68
|
+
Array(list).compact.first(MAX_COMPONENTS)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def truncate(value, max)
|
|
72
|
+
StringBound.truncate(value.to_s, max)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def blank?(value)
|
|
76
|
+
value.nil? || value.to_s.strip.empty?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Inventory
|
|
5
|
+
# Fail-open database server version collectors.
|
|
6
|
+
# Never transmits credentials, connection strings, or DB hostnames.
|
|
7
|
+
module DatabaseServer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def collect
|
|
11
|
+
return nil unless defined?(::ActiveRecord::Base)
|
|
12
|
+
return nil unless ::ActiveRecord::Base.respond_to?(:connection_pool)
|
|
13
|
+
|
|
14
|
+
pool = ::ActiveRecord::Base.connection_pool
|
|
15
|
+
return nil unless pool.respond_to?(:with_connection)
|
|
16
|
+
|
|
17
|
+
pool.with_connection do |connection|
|
|
18
|
+
return nil unless connection
|
|
19
|
+
|
|
20
|
+
adapter = connection.adapter_name.to_s.downcase
|
|
21
|
+
collector = collector_for(adapter)
|
|
22
|
+
return nil unless collector
|
|
23
|
+
|
|
24
|
+
collector.call(connection)
|
|
25
|
+
end
|
|
26
|
+
rescue StandardError
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def collector_for(adapter)
|
|
31
|
+
case adapter
|
|
32
|
+
when /postgre/
|
|
33
|
+
method(:postgresql)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def postgresql(connection)
|
|
38
|
+
version = nil
|
|
39
|
+
version = connection.select_value("SHOW server_version") if connection.respond_to?(:select_value)
|
|
40
|
+
version = version.to_s.strip
|
|
41
|
+
return nil if version.empty?
|
|
42
|
+
|
|
43
|
+
Components.build(
|
|
44
|
+
product: "PostgreSQL",
|
|
45
|
+
version: version,
|
|
46
|
+
scope: "database_server",
|
|
47
|
+
observation_method: "active_record.show_server_version",
|
|
48
|
+
evidence: {
|
|
49
|
+
"adapter" => "postgresql"
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -12,6 +12,7 @@ module Auditea
|
|
|
12
12
|
|
|
13
13
|
def call(env)
|
|
14
14
|
CurrentContext.clear!
|
|
15
|
+
Instrumentation::Exception::Deduper.clear!
|
|
15
16
|
request = ::Rack::Request.new(env)
|
|
16
17
|
CurrentContext.set(
|
|
17
18
|
"request_id" => env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"],
|
|
@@ -24,6 +25,7 @@ module Auditea
|
|
|
24
25
|
ensure
|
|
25
26
|
opportunistically_refresh_inventory
|
|
26
27
|
CurrentContext.clear!
|
|
28
|
+
Instrumentation::Exception::Deduper.clear!
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
private
|
data/lib/auditea/railtie.rb
CHANGED
|
@@ -18,14 +18,16 @@ 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 }
|
|
25
26
|
begin
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
Auditea.
|
|
27
|
+
# Schedule via existing single-flight background refresh — never block boot on
|
|
28
|
+
# detector processes or database inventory work.
|
|
29
|
+
Auditea::Inventory.schedule_refresh! if Auditea.configuration.capture_runtime_inventory?
|
|
30
|
+
Auditea::HostInventory.schedule_refresh! if Auditea.configuration.capture_host_inventory?
|
|
29
31
|
rescue StandardError => error
|
|
30
32
|
Auditea.handle_error(error)
|
|
31
33
|
end
|
data/lib/auditea/sanitizer.rb
CHANGED
|
@@ -50,8 +50,18 @@ module Auditea
|
|
|
50
50
|
path.to_s == "evidence.inventory.packages"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
def inventory_components_path?(path)
|
|
54
|
+
path.to_s == "evidence.inventory.components"
|
|
55
|
+
end
|
|
56
|
+
|
|
53
57
|
def array_limit_for(path)
|
|
54
|
-
inventory_packages_path?(path)
|
|
58
|
+
if inventory_packages_path?(path)
|
|
59
|
+
INVENTORY_PACKAGES_ARRAY_MAX
|
|
60
|
+
elsif inventory_components_path?(path)
|
|
61
|
+
40
|
|
62
|
+
else
|
|
63
|
+
DEFAULT_ARRAY_MAX
|
|
64
|
+
end
|
|
55
65
|
end
|
|
56
66
|
|
|
57
67
|
def deep_sanitize(value, depth:, path:, redacted:)
|
data/lib/auditea/version.rb
CHANGED