auditea 0.1.0.beta.1 → 0.1.0.beta.2
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 +46 -1
- data/DESIGN.md +13 -0
- data/README.md +128 -16
- data/RELEASE.md +184 -22
- data/auditea.gemspec +2 -2
- data/lib/auditea/configuration.rb +22 -1
- data/lib/auditea/host_inventory/background_refresh.rb +64 -0
- data/lib/auditea/host_inventory/collector.rb +246 -0
- data/lib/auditea/host_inventory/emitter.rb +105 -0
- data/lib/auditea/host_inventory.rb +37 -0
- data/lib/auditea/inventory/background_refresh.rb +64 -0
- data/lib/auditea/inventory/collector.rb +301 -0
- data/lib/auditea/inventory/digest.rb +59 -0
- data/lib/auditea/inventory/emitter.rb +105 -0
- data/lib/auditea/inventory/string_bound.rb +43 -0
- data/lib/auditea/inventory.rb +36 -0
- data/lib/auditea/middleware/context.rb +25 -0
- data/lib/auditea/railtie.rb +7 -0
- data/lib/auditea/sanitizer.rb +14 -1
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +24 -0
- metadata +13 -3
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "string_bound"
|
|
6
|
+
|
|
7
|
+
module Auditea
|
|
8
|
+
module Inventory
|
|
9
|
+
# Collects a normalized runtime + dependency inventory for AudiTea.
|
|
10
|
+
# Never includes filesystem paths, remote URLs, tokens, or env vars.
|
|
11
|
+
module Collector
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
MAX_PACKAGES = 400
|
|
14
|
+
MAX_FIELD_BYTES = 128
|
|
15
|
+
MAX_REQUIREMENT_BYTES = 256
|
|
16
|
+
# Leave headroom under Transport::Limits::SINGLE_MAX_BYTES after envelope/provenance.
|
|
17
|
+
MAX_INVENTORY_BYTES = 110_000
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def collect
|
|
22
|
+
packages, completeness, packages_total = collect_packages
|
|
23
|
+
inventory = {
|
|
24
|
+
"schema_version" => SCHEMA_VERSION,
|
|
25
|
+
"completeness" => completeness,
|
|
26
|
+
"runtime" => runtime_metadata,
|
|
27
|
+
"package_manager" => package_manager_metadata,
|
|
28
|
+
"openssl" => openssl_metadata,
|
|
29
|
+
"sdk" => {
|
|
30
|
+
"name" => "auditea",
|
|
31
|
+
"version" => VERSION
|
|
32
|
+
},
|
|
33
|
+
"packages" => packages,
|
|
34
|
+
"package_count" => packages.size
|
|
35
|
+
}
|
|
36
|
+
inventory["packages_total"] = packages_total if packages_total && packages_total != packages.size
|
|
37
|
+
|
|
38
|
+
framework = framework_metadata
|
|
39
|
+
inventory["framework"] = framework if framework
|
|
40
|
+
|
|
41
|
+
release = Auditea.configuration.release
|
|
42
|
+
inventory["application"] = { "release" => bound_string(release) } if present?(release)
|
|
43
|
+
|
|
44
|
+
inventory = enforce_size_bound(inventory)
|
|
45
|
+
inventory["digest"] = DigestComputer.compute(inventory)
|
|
46
|
+
inventory
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def runtime_metadata
|
|
50
|
+
{
|
|
51
|
+
"language" => "ruby",
|
|
52
|
+
"engine" => bound_string(RUBY_ENGINE),
|
|
53
|
+
"version" => bound_string(RUBY_VERSION),
|
|
54
|
+
"patchlevel" => bound_string(RUBY_PATCHLEVEL.to_s),
|
|
55
|
+
"platform" => bound_string(RUBY_PLATFORM),
|
|
56
|
+
"gem_platform" => bound_string(Gem::Platform.local.to_s)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def framework_metadata
|
|
61
|
+
return nil unless defined?(::Rails)
|
|
62
|
+
|
|
63
|
+
version =
|
|
64
|
+
if defined?(::Rails::VERSION::STRING)
|
|
65
|
+
::Rails::VERSION::STRING
|
|
66
|
+
elsif ::Rails.respond_to?(:version)
|
|
67
|
+
::Rails.version
|
|
68
|
+
end
|
|
69
|
+
return nil unless present?(version)
|
|
70
|
+
|
|
71
|
+
{ "name" => "rails", "version" => bound_string(version) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def package_manager_metadata
|
|
75
|
+
meta = { "rubygems_version" => bound_string(Gem::VERSION) }
|
|
76
|
+
meta["bundler_version"] = bound_string(::Bundler::VERSION) if defined?(::Bundler::VERSION)
|
|
77
|
+
meta
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def openssl_metadata
|
|
81
|
+
meta = {}
|
|
82
|
+
meta["extension_version"] = bound_string(::OpenSSL::VERSION) if defined?(::OpenSSL::VERSION)
|
|
83
|
+
if defined?(::OpenSSL) && ::OpenSSL.const_defined?(:OPENSSL_VERSION)
|
|
84
|
+
meta["build_version"] = bound_string(::OpenSSL::OPENSSL_VERSION)
|
|
85
|
+
end
|
|
86
|
+
if defined?(::OpenSSL) && ::OpenSSL.const_defined?(:OPENSSL_LIBRARY_VERSION)
|
|
87
|
+
meta["library_version"] = bound_string(::OpenSSL::OPENSSL_LIBRARY_VERSION)
|
|
88
|
+
end
|
|
89
|
+
meta
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def collect_packages
|
|
93
|
+
locked = current_platform_bundle_packages
|
|
94
|
+
return finalize_packages(locked, "bundle_locked") if locked
|
|
95
|
+
|
|
96
|
+
loaded = loaded_gem_packages
|
|
97
|
+
finalize_packages(loaded, "loaded_only")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def finalize_packages(packages, completeness)
|
|
101
|
+
packages = Array(packages)
|
|
102
|
+
packages = sort_packages(packages)
|
|
103
|
+
total = packages.size
|
|
104
|
+
if total > MAX_PACKAGES
|
|
105
|
+
packages = packages.first(MAX_PACKAGES)
|
|
106
|
+
completeness = "truncated"
|
|
107
|
+
end
|
|
108
|
+
[packages, completeness, total]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Prefer Bundler.definition.specs — the materialized SpecSet for the
|
|
112
|
+
# current platform / force_ruby_platform — not every lockfile platform variant.
|
|
113
|
+
# Only claim bundle_locked when every materialized spec normalizes and the
|
|
114
|
+
# direct-dependency map is available; otherwise return nil → loaded_only.
|
|
115
|
+
def current_platform_bundle_packages
|
|
116
|
+
return nil unless defined?(::Bundler)
|
|
117
|
+
return nil unless ::Bundler.respond_to?(:definition)
|
|
118
|
+
|
|
119
|
+
definition = ::Bundler.definition
|
|
120
|
+
return nil if definition.nil?
|
|
121
|
+
return nil unless definition.respond_to?(:specs)
|
|
122
|
+
|
|
123
|
+
specs = Array(definition.specs)
|
|
124
|
+
return nil if specs.empty?
|
|
125
|
+
|
|
126
|
+
directs = direct_dependency_map
|
|
127
|
+
return nil if directs.nil?
|
|
128
|
+
|
|
129
|
+
packages = []
|
|
130
|
+
specs.each do |spec|
|
|
131
|
+
pkg = package_from_spec(spec, directs)
|
|
132
|
+
# Do not silently omit a failed spec while still claiming bundle_locked.
|
|
133
|
+
return nil if pkg.nil?
|
|
134
|
+
|
|
135
|
+
packages << pkg
|
|
136
|
+
end
|
|
137
|
+
return nil if packages.empty?
|
|
138
|
+
|
|
139
|
+
packages
|
|
140
|
+
rescue StandardError
|
|
141
|
+
nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def loaded_gem_packages
|
|
145
|
+
directs = direct_dependency_map || {}
|
|
146
|
+
Gem.loaded_specs.values.filter_map { |spec| package_from_spec(spec, directs) }
|
|
147
|
+
rescue StandardError
|
|
148
|
+
[]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns a Hash of direct deps, or nil when the map cannot be obtained.
|
|
152
|
+
# Callers that claim bundle_locked must treat nil as failure (do not invent
|
|
153
|
+
# direct: false for every package).
|
|
154
|
+
def direct_dependency_map
|
|
155
|
+
return nil unless defined?(::Bundler)
|
|
156
|
+
return nil unless ::Bundler.respond_to?(:definition)
|
|
157
|
+
|
|
158
|
+
definition = ::Bundler.definition
|
|
159
|
+
return nil if definition.nil?
|
|
160
|
+
return nil unless definition.respond_to?(:dependencies)
|
|
161
|
+
|
|
162
|
+
definition.dependencies.each_with_object({}) do |dep, memo|
|
|
163
|
+
name = dep.name.to_s
|
|
164
|
+
next if name.empty?
|
|
165
|
+
|
|
166
|
+
memo[name] = {
|
|
167
|
+
"direct" => true,
|
|
168
|
+
"requirement" => bound_string(dep.requirement.to_s, MAX_REQUIREMENT_BYTES)
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
rescue StandardError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def package_from_spec(spec, directs)
|
|
176
|
+
name = bound_string(spec.name)
|
|
177
|
+
return nil if name.nil? || name.empty?
|
|
178
|
+
|
|
179
|
+
version = bound_string(spec.version.to_s)
|
|
180
|
+
return nil if version.nil? || version.empty?
|
|
181
|
+
|
|
182
|
+
platform = normalize_platform(spec)
|
|
183
|
+
entry = {
|
|
184
|
+
"ecosystem" => "RubyGems",
|
|
185
|
+
"name" => name,
|
|
186
|
+
"version" => version,
|
|
187
|
+
"platform" => platform,
|
|
188
|
+
"direct" => false,
|
|
189
|
+
"source_type" => classify_source(spec)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
direct = directs[name]
|
|
193
|
+
if direct
|
|
194
|
+
entry["direct"] = true
|
|
195
|
+
entry["requirement"] = direct["requirement"] if present?(direct["requirement"])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
revision = git_revision(spec)
|
|
199
|
+
entry["revision"] = bound_string(revision) if present?(revision)
|
|
200
|
+
|
|
201
|
+
entry
|
|
202
|
+
rescue StandardError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def normalize_platform(spec)
|
|
207
|
+
platform =
|
|
208
|
+
if spec.respond_to?(:platform) && spec.platform
|
|
209
|
+
spec.platform.to_s
|
|
210
|
+
else
|
|
211
|
+
"ruby"
|
|
212
|
+
end
|
|
213
|
+
platform = "ruby" if platform.empty?
|
|
214
|
+
bound_string(platform)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def classify_source(spec)
|
|
218
|
+
source = spec.respond_to?(:source) ? spec.source : nil
|
|
219
|
+
return "unknown" if source.nil?
|
|
220
|
+
|
|
221
|
+
class_name = source.class.name.to_s
|
|
222
|
+
case class_name
|
|
223
|
+
when /Source::Rubygems\z/, /RubygemsRemote\z/
|
|
224
|
+
"rubygems"
|
|
225
|
+
when /Source::Git\z/
|
|
226
|
+
"git"
|
|
227
|
+
when /Source::Path\z/, /Source::Gemspec\z/
|
|
228
|
+
"path"
|
|
229
|
+
else
|
|
230
|
+
return "git" if source.respond_to?(:revision) && present?(safe_revision(source))
|
|
231
|
+
return "path" if source.respond_to?(:path)
|
|
232
|
+
|
|
233
|
+
"unknown"
|
|
234
|
+
end
|
|
235
|
+
rescue StandardError
|
|
236
|
+
"unknown"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def git_revision(spec)
|
|
240
|
+
source = spec.respond_to?(:source) ? spec.source : nil
|
|
241
|
+
return nil unless source
|
|
242
|
+
|
|
243
|
+
class_name = source.class.name.to_s
|
|
244
|
+
return nil unless class_name.include?("Git")
|
|
245
|
+
|
|
246
|
+
safe_revision(source)
|
|
247
|
+
rescue StandardError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def safe_revision(source)
|
|
252
|
+
return nil unless source.respond_to?(:revision)
|
|
253
|
+
|
|
254
|
+
revision = source.revision
|
|
255
|
+
return nil unless revision.is_a?(String)
|
|
256
|
+
return nil unless revision.match?(/\A[0-9a-f]{7,40}\z/i)
|
|
257
|
+
|
|
258
|
+
revision.downcase
|
|
259
|
+
rescue StandardError
|
|
260
|
+
nil
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def sort_packages(packages)
|
|
264
|
+
packages.sort_by { |pkg| [pkg["name"].to_s, pkg["version"].to_s, pkg["platform"].to_s] }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def enforce_size_bound(inventory)
|
|
268
|
+
json_bytes = JSON.generate(inventory_for_size(inventory)).bytesize
|
|
269
|
+
return inventory if json_bytes <= MAX_INVENTORY_BYTES
|
|
270
|
+
|
|
271
|
+
packages = inventory["packages"].dup
|
|
272
|
+
total = inventory["packages_total"] || packages.size
|
|
273
|
+
while packages.size > 1
|
|
274
|
+
candidate = inventory.merge("packages" => packages)
|
|
275
|
+
break if JSON.generate(inventory_for_size(candidate)).bytesize <= MAX_INVENTORY_BYTES
|
|
276
|
+
|
|
277
|
+
packages.pop
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
inventory.merge(
|
|
281
|
+
"packages" => packages,
|
|
282
|
+
"package_count" => packages.size,
|
|
283
|
+
"packages_total" => total,
|
|
284
|
+
"completeness" => "truncated"
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def inventory_for_size(inventory)
|
|
289
|
+
inventory.except("digest")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def bound_string(value, max_bytes = MAX_FIELD_BYTES)
|
|
293
|
+
StringBound.truncate(value, max_bytes)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def present?(value)
|
|
297
|
+
!(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -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
|
|
@@ -21,6 +22,7 @@ module Auditea
|
|
|
21
22
|
)
|
|
22
23
|
@app.call(env)
|
|
23
24
|
ensure
|
|
25
|
+
opportunistically_refresh_inventory
|
|
24
26
|
CurrentContext.clear!
|
|
25
27
|
end
|
|
26
28
|
|
|
@@ -30,6 +32,29 @@ module Auditea
|
|
|
30
32
|
path = request.path.to_s
|
|
31
33
|
path.split("?", 2).first
|
|
32
34
|
end
|
|
35
|
+
|
|
36
|
+
def opportunistically_refresh_inventory
|
|
37
|
+
refresh_runtime_inventory
|
|
38
|
+
refresh_host_inventory
|
|
39
|
+
rescue StandardError => error
|
|
40
|
+
Auditea.handle_error(error)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def refresh_runtime_inventory
|
|
44
|
+
return unless Auditea.configuration.capture_runtime_inventory?
|
|
45
|
+
return unless Auditea::Inventory.collection_due?
|
|
46
|
+
|
|
47
|
+
# Never collect/deliver inline on the request path (even with sync transport).
|
|
48
|
+
Auditea::Inventory.schedule_refresh!
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def refresh_host_inventory
|
|
52
|
+
return unless Auditea.configuration.capture_host_inventory?
|
|
53
|
+
return unless Auditea::HostInventory.collection_due?
|
|
54
|
+
|
|
55
|
+
# Never collect/deliver inline on the request path (even with sync transport).
|
|
56
|
+
Auditea::HostInventory.schedule_refresh!
|
|
57
|
+
end
|
|
33
58
|
end
|
|
34
59
|
end
|
|
35
60
|
end
|
data/lib/auditea/railtie.rb
CHANGED
|
@@ -22,6 +22,13 @@ module Auditea
|
|
|
22
22
|
|
|
23
23
|
config.after_initialize do
|
|
24
24
|
at_exit { Auditea.shutdown }
|
|
25
|
+
begin
|
|
26
|
+
# Startup capture is explicit (force) so long-lived refresh can stay gated.
|
|
27
|
+
Auditea.capture_runtime_inventory! if Auditea.configuration.capture_runtime_inventory?
|
|
28
|
+
Auditea.capture_host_inventory! if Auditea.configuration.capture_host_inventory?
|
|
29
|
+
rescue StandardError => error
|
|
30
|
+
Auditea.handle_error(error)
|
|
31
|
+
end
|
|
25
32
|
end
|
|
26
33
|
end
|
|
27
34
|
end
|
data/lib/auditea/sanitizer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/auditea/version.rb
CHANGED
data/lib/auditea.rb
CHANGED
|
@@ -28,6 +28,8 @@ 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"
|
|
33
35
|
require_relative "auditea/instrumentation/logger_adapter"
|
|
@@ -54,6 +56,8 @@ module Auditea
|
|
|
54
56
|
@configuration = Configuration.new
|
|
55
57
|
@client&.shutdown
|
|
56
58
|
@client = nil
|
|
59
|
+
Inventory.reset!
|
|
60
|
+
HostInventory.reset!
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def client
|
|
@@ -97,6 +101,26 @@ module Auditea
|
|
|
97
101
|
handle_error(error)
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
# Opt-in runtime + dependency inventory (`runtime.inventory`). Default off.
|
|
105
|
+
# Does not raise on collection failure unless raise_errors is enabled.
|
|
106
|
+
def capture_runtime_inventory!(force: true)
|
|
107
|
+
Inventory.capture!(force: force)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Bounded refresh for long-lived processes (~daily when unchanged).
|
|
111
|
+
def maybe_capture_runtime_inventory!
|
|
112
|
+
Inventory.maybe_capture!
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Opt-in host/platform inventory (`host.inventory`). Default off.
|
|
116
|
+
def capture_host_inventory!(force: true)
|
|
117
|
+
HostInventory.capture!(force: force)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def maybe_capture_host_inventory!
|
|
121
|
+
HostInventory.maybe_capture!
|
|
122
|
+
end
|
|
123
|
+
|
|
100
124
|
def flush(timeout: 5)
|
|
101
125
|
client.flush(timeout: timeout)
|
|
102
126
|
rescue StandardError => error
|