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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -1
- data/DESIGN.md +13 -0
- data/README.md +136 -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/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/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 +27 -0
- data/lib/auditea/railtie.rb +8 -0
- data/lib/auditea/sanitizer.rb +14 -1
- data/lib/auditea/version.rb +1 -1
- data/lib/auditea.rb +45 -0
- metadata +15 -3
|
@@ -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
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Auditea
|
|
4
|
+
module Inventory
|
|
5
|
+
# Process-local, single-flight background refresh for runtime.inventory.
|
|
6
|
+
# Middleware only schedules; collection/delivery never run inline on the Rack path.
|
|
7
|
+
module BackgroundRefresh
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def schedule!
|
|
11
|
+
return false unless Auditea.configuration.capture_runtime_inventory?
|
|
12
|
+
return false unless Emitter.collection_due?
|
|
13
|
+
return false unless claim_slot!
|
|
14
|
+
|
|
15
|
+
thread = Thread.new { run_capture }
|
|
16
|
+
thread.name = "auditea-runtime-inventory" if thread.respond_to?(:name=)
|
|
17
|
+
true
|
|
18
|
+
rescue StandardError => error
|
|
19
|
+
release_slot!
|
|
20
|
+
Auditea.handle_error(error)
|
|
21
|
+
false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reset!
|
|
25
|
+
state_mutex.synchronize do
|
|
26
|
+
@inflight = false
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def inflight?
|
|
31
|
+
state_mutex.synchronize { !!@inflight }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def claim_slot!
|
|
35
|
+
state_mutex.synchronize do
|
|
36
|
+
return false if @inflight
|
|
37
|
+
|
|
38
|
+
@inflight = true
|
|
39
|
+
true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
private_class_method :claim_slot!
|
|
43
|
+
|
|
44
|
+
def release_slot!
|
|
45
|
+
state_mutex.synchronize { @inflight = false }
|
|
46
|
+
end
|
|
47
|
+
private_class_method :release_slot!
|
|
48
|
+
|
|
49
|
+
def run_capture
|
|
50
|
+
Emitter.capture!(force: false)
|
|
51
|
+
rescue StandardError => error
|
|
52
|
+
Auditea.handle_error(error)
|
|
53
|
+
ensure
|
|
54
|
+
release_slot!
|
|
55
|
+
end
|
|
56
|
+
private_class_method :run_capture
|
|
57
|
+
|
|
58
|
+
def state_mutex
|
|
59
|
+
@state_mutex ||= Mutex.new
|
|
60
|
+
end
|
|
61
|
+
private_class_method :state_mutex
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -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
|