hyperprobe-agent 1.2.27.pre.3-java
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 +7 -0
- data/LICENSE +13 -0
- data/README.md +386 -0
- data/lib/hyperprobe/agent.rb +537 -0
- data/lib/hyperprobe/core/broker.rb +183 -0
- data/lib/hyperprobe/core/evaluator.rb +418 -0
- data/lib/hyperprobe/core/lexical_scope.rb +222 -0
- data/lib/hyperprobe/core/logger.rb +198 -0
- data/lib/hyperprobe/core/monitoring_engine.rb +857 -0
- data/lib/hyperprobe/core/quota.rb +111 -0
- data/lib/hyperprobe/core/safe_ast_validator.rb +170 -0
- data/lib/hyperprobe/core/safety.rb +256 -0
- data/lib/hyperprobe/core/serializer.rb +437 -0
- data/lib/hyperprobe/core/trace_extractor.rb +158 -0
- data/lib/hyperprobe/core/transports/java_grpc.rb +57 -0
- data/lib/hyperprobe/jars/hyperprobe-grpc.jar +0 -0
- data/lib/hyperprobe/lambda.rb +92 -0
- data/lib/hyperprobe/protos/agent_descriptor.rb +7 -0
- data/lib/hyperprobe/protos/agent_pb.rb +33 -0
- data/lib/hyperprobe/protos/agent_services_pb.rb +31 -0
- data/lib/hyperprobe/protos/java_messages.rb +199 -0
- data/lib/hyperprobe/protos.rb +14 -0
- data/lib/hyperprobe/railtie.rb +22 -0
- data/lib/hyperprobe/version.rb +5 -0
- data/lib/hyperprobe-agent.rb +3 -0
- data/lib/hyperprobe.rb +282 -0
- data/transport/README.md +98 -0
- data/transport/THIRD_PARTY_NOTICES.md +57 -0
- data/transport/pom.xml +78 -0
- data/transport/src/main/java/co/hyperprobe/transport/GrpcTransport.java +118 -0
- data/transport/src/main/java/co/hyperprobe/transport/ProtoCodec.java +43 -0
- metadata +117 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
if RUBY_PLATFORM !~ /java/
|
|
8
|
+
require 'grpc'
|
|
9
|
+
else
|
|
10
|
+
require_relative 'transports/java_grpc'
|
|
11
|
+
end
|
|
12
|
+
require_relative '../protos'
|
|
13
|
+
|
|
14
|
+
module HyperProbe
|
|
15
|
+
module Core
|
|
16
|
+
class BrokerClient
|
|
17
|
+
attr_reader :agent_id, :service_id, :environment, :commit_sha, :agent_version, :stub
|
|
18
|
+
|
|
19
|
+
def initialize(
|
|
20
|
+
broker_url:,
|
|
21
|
+
service_id:,
|
|
22
|
+
environment:,
|
|
23
|
+
commit_sha:,
|
|
24
|
+
agent_id: nil,
|
|
25
|
+
agent_version: '1.0.0',
|
|
26
|
+
rpc_timeout_sec: 10.0,
|
|
27
|
+
enable_keep_alive: true
|
|
28
|
+
)
|
|
29
|
+
@service_id = service_id.to_s
|
|
30
|
+
@environment = environment.to_s
|
|
31
|
+
@commit_sha = commit_sha.to_s
|
|
32
|
+
@agent_id = agent_id || SecureRandom.uuid
|
|
33
|
+
@agent_version = agent_version.to_s
|
|
34
|
+
@rpc_timeout_sec = valid_timeout(rpc_timeout_sec)
|
|
35
|
+
@hostname = ENV['HOSTNAME'] || Socket.gethostname rescue 'unknown'
|
|
36
|
+
@is_jruby = RUBY_PLATFORM =~ /java/
|
|
37
|
+
|
|
38
|
+
if @is_jruby
|
|
39
|
+
@transport = Transports::JavaGrpc.new(broker_url, @service_id, enable_keep_alive)
|
|
40
|
+
else
|
|
41
|
+
@calls_mutex = Mutex.new
|
|
42
|
+
@active_calls = []
|
|
43
|
+
@closed = false
|
|
44
|
+
target, creds, channel_args = parse_broker_url(broker_url, enable_keep_alive)
|
|
45
|
+
@channel = GRPC::Core::Channel.new(target, channel_args, creds)
|
|
46
|
+
@stub = Hyperprobe::Agent::V1::AgentBroker::Stub.new(target, creds, channel_override: @channel)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def get_probes(timeout_sec = nil)
|
|
51
|
+
deadline = calculate_deadline(timeout_sec || @rpc_timeout_sec)
|
|
52
|
+
req = Hyperprobe::Agent::V1::GetProbesRequest.new(
|
|
53
|
+
agent_id: @agent_id,
|
|
54
|
+
service_id: @service_id,
|
|
55
|
+
environment: @environment,
|
|
56
|
+
commit_sha: @commit_sha,
|
|
57
|
+
language: @is_jruby ? 'jruby' : 'ruby',
|
|
58
|
+
agent_version: @agent_version,
|
|
59
|
+
hostname: @hostname
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
if @is_jruby
|
|
63
|
+
call_jruby_rpc('hyperprobe.agent.v1.AgentBroker/GetProbes', req, Hyperprobe::Agent::V1::GetProbesResponse, deadline)
|
|
64
|
+
else
|
|
65
|
+
call_cruby_rpc(:get_probes, req, deadline)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def report_telemetry(events, timeout_sec = nil)
|
|
70
|
+
return [] if events.nil? || events.empty?
|
|
71
|
+
|
|
72
|
+
deadline = calculate_deadline(timeout_sec || @rpc_timeout_sec)
|
|
73
|
+
proto_events = events.map { |evt| build_telemetry_event_proto(evt) }
|
|
74
|
+
batch = Hyperprobe::Agent::V1::TelemetryBatch.new(agent_id: @agent_id, events: proto_events)
|
|
75
|
+
|
|
76
|
+
response = if @is_jruby
|
|
77
|
+
call_jruby_rpc('hyperprobe.agent.v1.AgentBroker/ReportTelemetry', batch, Hyperprobe::Agent::V1::TelemetryResponse, deadline)
|
|
78
|
+
else
|
|
79
|
+
call_cruby_rpc(:report_telemetry, batch, deadline)
|
|
80
|
+
end
|
|
81
|
+
response.finished_probe_ids.to_a
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def estimate_event_size(event)
|
|
85
|
+
proto = build_telemetry_event_proto(event)
|
|
86
|
+
proto.respond_to?(:serialized_size) ? proto.serialized_size : proto.to_proto.bytesize
|
|
87
|
+
rescue StandardError
|
|
88
|
+
JSON.generate(event).bytesize rescue 512
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def shutdown
|
|
92
|
+
if @is_jruby
|
|
93
|
+
@transport.shutdown
|
|
94
|
+
else
|
|
95
|
+
calls = @calls_mutex.synchronize do
|
|
96
|
+
@closed = true
|
|
97
|
+
@active_calls.dup
|
|
98
|
+
end
|
|
99
|
+
# Closing a C-core channel alone does not cancel outstanding Ruby operations.
|
|
100
|
+
calls.each(&:cancel)
|
|
101
|
+
@channel.close
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def call_cruby_rpc(method, request, deadline)
|
|
108
|
+
operation = @stub.public_send(method, request, metadata: { 'x-hp-service-id' => @service_id },
|
|
109
|
+
deadline: deadline, return_op: true)
|
|
110
|
+
@calls_mutex.synchronize do
|
|
111
|
+
raise IOError, 'Broker transport is shut down' if @closed
|
|
112
|
+
|
|
113
|
+
@active_calls << operation
|
|
114
|
+
end
|
|
115
|
+
operation.execute
|
|
116
|
+
ensure
|
|
117
|
+
@calls_mutex.synchronize { @active_calls.delete(operation) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def call_jruby_rpc(method_path, request_proto, response_class, deadline)
|
|
121
|
+
payload = request_proto.to_proto
|
|
122
|
+
response_class.decode(@transport.call(method_path, payload, deadline))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def build_telemetry_event_proto(evt)
|
|
126
|
+
return evt if evt.is_a?(Hyperprobe::Agent::V1::TelemetryEvent)
|
|
127
|
+
|
|
128
|
+
stack_frames = Array(evt[:stack_frames]).map do |frame|
|
|
129
|
+
Hyperprobe::Agent::V1::StackFrame.new(
|
|
130
|
+
function_name: frame[:function_name].to_s,
|
|
131
|
+
file_name: frame[:file_name].to_s,
|
|
132
|
+
line_number: frame[:line_number].to_i,
|
|
133
|
+
column_number: frame[:column_number].to_i,
|
|
134
|
+
class_name: frame[:class_name] ? frame[:class_name].to_s : nil
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
proto_params = {
|
|
139
|
+
probe_id: evt[:probe_id].to_s,
|
|
140
|
+
timestamp_ms: (evt[:timestamp_ms] || (Time.now.to_f * 1000)).to_i,
|
|
141
|
+
stack_frames: stack_frames,
|
|
142
|
+
captured_vars_json: evt[:captured_vars_json].to_s,
|
|
143
|
+
watch_results_json: evt[:watch_results_json].to_s,
|
|
144
|
+
evaluated_log: evt[:evaluated_log].to_s,
|
|
145
|
+
metric_value: evt[:metric_value].to_f,
|
|
146
|
+
capture_error: evt[:capture_error].to_s
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
proto_params[:trace_id] = evt[:trace_id].to_s if evt[:trace_id] && !evt[:trace_id].to_s.empty?
|
|
150
|
+
|
|
151
|
+
Hyperprobe::Agent::V1::TelemetryEvent.new(proto_params)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def calculate_deadline(timeout_sec)
|
|
155
|
+
now = @is_jruby ? Process.clock_gettime(Process::CLOCK_MONOTONIC) : Time.now
|
|
156
|
+
now + valid_timeout(timeout_sec)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def valid_timeout(value)
|
|
160
|
+
timeout = Float(value)
|
|
161
|
+
raise ArgumentError, 'RPC timeout must be finite and positive' unless timeout.finite? && timeout.positive?
|
|
162
|
+
|
|
163
|
+
timeout
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def parse_broker_url(url, enable_keep_alive)
|
|
167
|
+
raw_url = url.to_s.strip
|
|
168
|
+
channel_args = {}
|
|
169
|
+
if enable_keep_alive
|
|
170
|
+
channel_args['grpc.keepalive_time_ms'] = 60_000
|
|
171
|
+
channel_args['grpc.keepalive_timeout_ms'] = 20_000
|
|
172
|
+
channel_args['grpc.keepalive_permit_without_calls'] = 1
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if raw_url.start_with?('https://')
|
|
176
|
+
[raw_url.sub('https://', ''), GRPC::Core::ChannelCredentials.new, channel_args]
|
|
177
|
+
else
|
|
178
|
+
[raw_url.sub(/\Ahttp:\/\//, ''), :this_channel_is_insecure, channel_args]
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'safe_ast_validator'
|
|
4
|
+
require 'objspace' unless RUBY_PLATFORM.include?('java')
|
|
5
|
+
|
|
6
|
+
module HyperProbe
|
|
7
|
+
module Core
|
|
8
|
+
class Evaluator
|
|
9
|
+
MAX_STRING_BYTES = 4096
|
|
10
|
+
MAX_INTEGER_BITS = 1024
|
|
11
|
+
MAX_COLLECTION_ITEMS = 128
|
|
12
|
+
MAX_STEPS = 512
|
|
13
|
+
MAX_TEMPLATE_BYTES = 16_384
|
|
14
|
+
MAX_PLACEHOLDERS = 32
|
|
15
|
+
MEMORY_SIZE = ObjectSpace.method(:memsize_of) if ObjectSpace.respond_to?(:memsize_of)
|
|
16
|
+
JS_FLOAT_PREFIX_RE = /\A\s*([+-]?(?:(?:[0-9]+\.?[0-9]*)|(?:\.[0-9]+))(?:[eE][+-]?[0-9]+)?)/.freeze
|
|
17
|
+
CLASS = Object.instance_method(:class)
|
|
18
|
+
IDENTITY = BasicObject.instance_method(:equal?)
|
|
19
|
+
LOCAL_GET = Binding.instance_method(:local_variable_get)
|
|
20
|
+
LOCAL_DEFINED = Binding.instance_method(:local_variable_defined?)
|
|
21
|
+
BINDING_EVAL = Binding.instance_method(:eval)
|
|
22
|
+
TYPES = { string: String, integer: Integer, float: Float, symbol: Symbol,
|
|
23
|
+
array: Array, hash: Hash, binding: Binding, nil: NilClass,
|
|
24
|
+
true: TrueClass, false: FalseClass }.freeze
|
|
25
|
+
SYMBOL_NAME = if Symbol.method_defined?(:name)
|
|
26
|
+
Symbol.instance_method(:name)
|
|
27
|
+
elsif RUBY_PLATFORM.include?('java')
|
|
28
|
+
# JRuby 9.3 returns a constant-sized copy-on-write wrapper.
|
|
29
|
+
Symbol.instance_method(:to_s)
|
|
30
|
+
end
|
|
31
|
+
# Bind captured builtin implementations, bypassing singleton overrides.
|
|
32
|
+
# Only allowlisted interpreter operations reach this table.
|
|
33
|
+
BUILTINS = {
|
|
34
|
+
string: %i[bytesize byteslice [] length + == < <= > >= inspect to_f],
|
|
35
|
+
integer: %i[bit_length + - * / % == < <= > >= +@ -@ to_f to_s],
|
|
36
|
+
float: %i[+ - * / % == < <= > >= +@ -@ to_f to_s finite? nan? infinite? to_i],
|
|
37
|
+
symbol: %i[== name inspect],
|
|
38
|
+
array: %i[length []],
|
|
39
|
+
hash: %i[length each_pair compare_by_identity?]
|
|
40
|
+
}.each_with_object({}) do |(kind, methods), result|
|
|
41
|
+
result[kind] = methods.to_h do |method|
|
|
42
|
+
implementation = kind == :symbol && method == :name ? SYMBOL_NAME : TYPES.fetch(kind).instance_method(method)
|
|
43
|
+
[method, implementation]
|
|
44
|
+
end.freeze
|
|
45
|
+
end.freeze
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
def safe_evaluation_enabled?(disable = nil)
|
|
49
|
+
disable = ENV['HYPERPROBE_DISABLE_SAFE_EVALUATION'] if disable.nil?
|
|
50
|
+
!(disable == true || (disable.is_a?(String) && disable.strip.downcase == 'true'))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def eval_condition(condition, binding_ctx, safe: safe_evaluation_enabled?)
|
|
54
|
+
return true if blank?(condition)
|
|
55
|
+
|
|
56
|
+
truthy?(evaluate(condition, binding_ctx, safe: safe))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def evaluate_watches(watch_expressions, binding_ctx, safe: safe_evaluation_enabled?)
|
|
60
|
+
results = {}
|
|
61
|
+
return results if kind(watch_expressions) == :nil
|
|
62
|
+
raise SecurityError, 'Watches must be an exact builtin Array' unless kind(watch_expressions) == :array
|
|
63
|
+
size = builtin(watch_expressions, :length)
|
|
64
|
+
raise SecurityError, 'Too many watch expressions' if size > MAX_COLLECTION_ITEMS
|
|
65
|
+
|
|
66
|
+
size.times do |index|
|
|
67
|
+
expression = nil
|
|
68
|
+
begin
|
|
69
|
+
expression = SafeASTValidator.source(builtin(watch_expressions, :[], index))
|
|
70
|
+
next if expression.strip.empty?
|
|
71
|
+
results[expression] = evaluate(expression, binding_ctx, safe: safe)
|
|
72
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
73
|
+
results[expression || "<invalid expression #{index}>"] = "Error: #{e.class.name}: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
results
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def evaluate_log_template(template, binding_ctx, safe: safe_evaluation_enabled?)
|
|
80
|
+
return '' if kind(template) == :nil
|
|
81
|
+
|
|
82
|
+
text = SafeASTValidator.source(template, MAX_TEMPLATE_BYTES)
|
|
83
|
+
characters = text.chars
|
|
84
|
+
output = String.new
|
|
85
|
+
cursor = 0
|
|
86
|
+
placeholders = 0
|
|
87
|
+
while cursor < characters.length
|
|
88
|
+
unless ['$', '#'].include?(characters[cursor]) && characters[cursor + 1] == '{'
|
|
89
|
+
output << characters[cursor]
|
|
90
|
+
cursor += 1
|
|
91
|
+
next
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
placeholders += 1
|
|
95
|
+
raise SecurityError, 'Too many log placeholders' if placeholders > MAX_PLACEHOLDERS
|
|
96
|
+
start = cursor + 2
|
|
97
|
+
cursor = start
|
|
98
|
+
depth = 1
|
|
99
|
+
quote = nil
|
|
100
|
+
escaped = false
|
|
101
|
+
while cursor < characters.length && depth > 0
|
|
102
|
+
char = characters[cursor]
|
|
103
|
+
if quote
|
|
104
|
+
if escaped
|
|
105
|
+
escaped = false
|
|
106
|
+
elsif char == '\\'
|
|
107
|
+
escaped = true
|
|
108
|
+
elsif char == quote
|
|
109
|
+
quote = nil
|
|
110
|
+
end
|
|
111
|
+
elsif ["'", '"'].include?(char)
|
|
112
|
+
quote = char
|
|
113
|
+
elsif char == '{'
|
|
114
|
+
depth += 1
|
|
115
|
+
elsif char == '}'
|
|
116
|
+
depth -= 1
|
|
117
|
+
end
|
|
118
|
+
cursor += 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
begin
|
|
122
|
+
raise ArgumentError, 'Unterminated log placeholder' unless depth.zero?
|
|
123
|
+
value = evaluate(characters[start...(cursor - 1)].join, binding_ctx, safe: safe)
|
|
124
|
+
rendered = display(value, [MAX_STEPS], 0)
|
|
125
|
+
raise SecurityError, 'Log output is too large' if output.bytesize + rendered.bytesize > MAX_TEMPLATE_BYTES
|
|
126
|
+
output << rendered
|
|
127
|
+
rescue StandardError, ScriptError, SecurityError => e
|
|
128
|
+
output << "<Error: #{e.class.name}: #{e.message}>"
|
|
129
|
+
end
|
|
130
|
+
raise SecurityError, 'Log output is too large' if output.bytesize > MAX_TEMPLATE_BYTES
|
|
131
|
+
end
|
|
132
|
+
raise SecurityError, 'Log output is too large' if output.bytesize > MAX_TEMPLATE_BYTES
|
|
133
|
+
output
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def evaluate_metric(metric_expression, binding_ctx, safe: safe_evaluation_enabled?)
|
|
137
|
+
return nil if blank?(metric_expression)
|
|
138
|
+
|
|
139
|
+
coerce_metric_value(evaluate(metric_expression, binding_ctx, safe: safe))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def evaluate_correlation(correlation_expression, binding_ctx, safe: safe_evaluation_enabled?)
|
|
143
|
+
return 'static-singleton' if blank?(correlation_expression)
|
|
144
|
+
|
|
145
|
+
normalize_correlation_value(evaluate(correlation_expression, binding_ctx, safe: safe))
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def coerce_metric_value(value)
|
|
149
|
+
case kind(value)
|
|
150
|
+
when :integer, :float
|
|
151
|
+
checked(value)
|
|
152
|
+
number = builtin(value, :to_f)
|
|
153
|
+
when :string
|
|
154
|
+
match = JS_FLOAT_PREFIX_RE.match(copy_string(value))
|
|
155
|
+
number = match && match[1].to_f
|
|
156
|
+
else
|
|
157
|
+
return nil
|
|
158
|
+
end
|
|
159
|
+
number && builtin(number, :finite?) ? number : nil
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def normalize_correlation_value(value)
|
|
163
|
+
case kind(value)
|
|
164
|
+
when :nil
|
|
165
|
+
'static-singleton'
|
|
166
|
+
when :true, :false
|
|
167
|
+
raise TypeError, 'Correlation expression must evaluate to a string or number, got boolean'
|
|
168
|
+
when :string
|
|
169
|
+
copy = copy_string(value)
|
|
170
|
+
raise StandardError, copy if copy.start_with?('Error: ')
|
|
171
|
+
copy.freeze
|
|
172
|
+
when :integer
|
|
173
|
+
builtin(checked(value), :to_s).freeze
|
|
174
|
+
when :float
|
|
175
|
+
return 'NaN' if builtin(value, :nan?)
|
|
176
|
+
infinite = builtin(value, :infinite?)
|
|
177
|
+
return infinite == 1 ? 'Infinity' : '-Infinity' if infinite
|
|
178
|
+
return builtin(checked(builtin(value, :to_i)), :to_s).freeze if builtin(value, :%, 1.0).zero?
|
|
179
|
+
|
|
180
|
+
builtin(value, :to_s).freeze
|
|
181
|
+
else
|
|
182
|
+
type = { array: 'Array', hash: 'Hash', symbol: 'Symbol' }[kind(value)] || 'unsupported object'
|
|
183
|
+
raise TypeError, "Correlation expression must evaluate to a string or number, got #{type}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
private
|
|
188
|
+
|
|
189
|
+
def kind(value)
|
|
190
|
+
type = CLASS.bind(value).call
|
|
191
|
+
TYPES.each { |name, trusted| return name if IDENTITY.bind(trusted).call(type) }
|
|
192
|
+
:unsupported
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def builtin(value, method, *args, &block)
|
|
196
|
+
implementation = BUILTINS.fetch(kind(value)).fetch(method)
|
|
197
|
+
implementation.bind(value).call(*args, &block)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def truthy?(value)
|
|
201
|
+
!IDENTITY.bind(value).call(nil) && !IDENTITY.bind(value).call(false)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def blank?(expression)
|
|
205
|
+
kind(expression) == :nil || SafeASTValidator.source(expression).strip.empty?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def copy_string(value)
|
|
209
|
+
raise SecurityError, 'String exceeds expression limit' if builtin(value, :bytesize) > MAX_STRING_BYTES
|
|
210
|
+
copy = builtin(value, :byteslice, 0, MAX_STRING_BYTES + 1)
|
|
211
|
+
raise SecurityError, 'String exceeds expression limit' if copy.bytesize > MAX_STRING_BYTES
|
|
212
|
+
copy
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def checked(value)
|
|
216
|
+
case kind(value)
|
|
217
|
+
when :integer
|
|
218
|
+
raise SecurityError, 'Number exceeds expression limit' if builtin(value, :bit_length) > MAX_INTEGER_BITS
|
|
219
|
+
when :string
|
|
220
|
+
return copy_string(value)
|
|
221
|
+
when :symbol
|
|
222
|
+
if builtin(builtin(value, :name), :bytesize) > MAX_STRING_BYTES
|
|
223
|
+
raise SecurityError, 'Symbol exceeds expression limit'
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
value
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def step!(budget)
|
|
230
|
+
budget[0] -= 1
|
|
231
|
+
raise SecurityError, 'Expression step limit exceeded' if budget[0] < 0
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def evaluate(expression, binding_ctx, safe:)
|
|
235
|
+
raise SecurityError, 'Expected an exact builtin Binding' unless kind(binding_ctx) == :binding
|
|
236
|
+
if safe == false
|
|
237
|
+
# Explicit opt-in executes Ruby in the hit binding, with application
|
|
238
|
+
# permissions and side effects. Source and output size limits remain.
|
|
239
|
+
return BINDING_EVAL.bind(binding_ctx).call(SafeASTValidator.source(expression), '(hyperprobe)', 1)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
tree = SafeASTValidator.parse!(expression)
|
|
243
|
+
execute(tree, binding_ctx, [MAX_STEPS])
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def execute(node, binding_ctx, budget)
|
|
247
|
+
step!(budget)
|
|
248
|
+
case node[0]
|
|
249
|
+
when :literal
|
|
250
|
+
checked(node[1])
|
|
251
|
+
when :local
|
|
252
|
+
unless LOCAL_DEFINED.bind(binding_ctx).call(node[1])
|
|
253
|
+
raise NameError, "Unknown local variable '#{node[1]}'"
|
|
254
|
+
end
|
|
255
|
+
checked(LOCAL_GET.bind(binding_ctx).call(node[1]))
|
|
256
|
+
when :array
|
|
257
|
+
node[1].map { |item| execute(item, binding_ctx, budget) }
|
|
258
|
+
when :hash
|
|
259
|
+
node[1].each_with_object({}) do |(key_node, value_node), hash|
|
|
260
|
+
key = primitive_key(execute(key_node, binding_ctx, budget))
|
|
261
|
+
hash[key] = execute(value_node, binding_ctx, budget)
|
|
262
|
+
end
|
|
263
|
+
when :lookup
|
|
264
|
+
receiver = execute(node[1], binding_ctx, budget)
|
|
265
|
+
index = execute(node[2], binding_ctx, budget)
|
|
266
|
+
lookup(receiver, index, budget)
|
|
267
|
+
when :length
|
|
268
|
+
receiver = execute(node[1], binding_ctx, budget)
|
|
269
|
+
unless %i[string array hash].include?(kind(receiver))
|
|
270
|
+
raise SecurityError, 'length/size requires an exact builtin String, Array or Hash'
|
|
271
|
+
end
|
|
272
|
+
builtin(receiver, :length)
|
|
273
|
+
when :conditional
|
|
274
|
+
branch = truthy?(execute(node[1], binding_ctx, budget)) ? node[2] : node[3]
|
|
275
|
+
execute(branch, binding_ctx, budget)
|
|
276
|
+
when :unary
|
|
277
|
+
value = execute(node[2], binding_ctx, budget)
|
|
278
|
+
return !truthy?(value) if %i[! not].include?(node[1])
|
|
279
|
+
raise SecurityError, 'Unary arithmetic requires builtin numbers' unless numeric?(value)
|
|
280
|
+
checked(builtin(value, node[1]))
|
|
281
|
+
when :binary
|
|
282
|
+
left = execute(node[2], binding_ctx, budget)
|
|
283
|
+
operator = node[1]
|
|
284
|
+
if %i[&& and].include?(operator)
|
|
285
|
+
return truthy?(left) ? execute(node[3], binding_ctx, budget) : left
|
|
286
|
+
elsif %i[|| or].include?(operator)
|
|
287
|
+
return truthy?(left) ? left : execute(node[3], binding_ctx, budget)
|
|
288
|
+
end
|
|
289
|
+
right = execute(node[3], binding_ctx, budget)
|
|
290
|
+
binary(left, operator, right)
|
|
291
|
+
else
|
|
292
|
+
raise SecurityError, 'Unsupported expression instruction'
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def numeric?(value)
|
|
297
|
+
%i[integer float].include?(kind(value))
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def primitive_key(value)
|
|
301
|
+
unless %i[string symbol integer float nil true false].include?(kind(value))
|
|
302
|
+
raise SecurityError, 'Hash keys must be builtin primitives'
|
|
303
|
+
end
|
|
304
|
+
if kind(value) == :float && !builtin(value, :finite?)
|
|
305
|
+
raise SecurityError, 'Hash keys must be finite'
|
|
306
|
+
end
|
|
307
|
+
# Numeric and symbol objects cannot carry singleton overrides. Strings
|
|
308
|
+
# must be copied before insertion, which invokes hash/eql? internally.
|
|
309
|
+
checked(value)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def lookup(receiver, index, budget)
|
|
313
|
+
case kind(receiver)
|
|
314
|
+
when :array, :string
|
|
315
|
+
raise SecurityError, 'Subscript must be a builtin Integer' unless kind(index) == :integer
|
|
316
|
+
# Oversized indices must not reach native long conversion.
|
|
317
|
+
return nil if builtin(index, :bit_length) > 62
|
|
318
|
+
checked(builtin(receiver, :[], index))
|
|
319
|
+
when :hash
|
|
320
|
+
key = primitive_key(index)
|
|
321
|
+
raise SecurityError, 'Identity hash lookup is unsupported' if builtin(receiver, :compare_by_identity?)
|
|
322
|
+
raise SecurityError, 'Hash exceeds lookup limit' if builtin(receiver, :length) > MAX_COLLECTION_ITEMS
|
|
323
|
+
if defined?(MEMORY_SIZE) && MEMORY_SIZE.call(receiver) > 131_072
|
|
324
|
+
raise SecurityError, 'Hash storage exceeds lookup limit'
|
|
325
|
+
end
|
|
326
|
+
# Hash#[]/fetch can execute default procs or custom hash/eql? keys.
|
|
327
|
+
# A bounded scan avoids all of those hooks, even on missing keys.
|
|
328
|
+
builtin(receiver, :each_pair) do |stored, value|
|
|
329
|
+
step!(budget)
|
|
330
|
+
stored = primitive_key(stored)
|
|
331
|
+
next unless kind(stored) == kind(key)
|
|
332
|
+
return checked(value) if primitive_equal?(stored, key)
|
|
333
|
+
end
|
|
334
|
+
nil
|
|
335
|
+
else
|
|
336
|
+
raise SecurityError, 'Subscript requires an exact builtin Array, Hash or String'
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
def primitive_equal?(left, right)
|
|
341
|
+
left_kind = kind(left)
|
|
342
|
+
right_kind = kind(right)
|
|
343
|
+
allowed = %i[string symbol integer float nil true false]
|
|
344
|
+
unless allowed.include?(left_kind) && allowed.include?(right_kind)
|
|
345
|
+
raise SecurityError, 'Comparison requires builtin primitives'
|
|
346
|
+
end
|
|
347
|
+
if numeric?(left) && numeric?(right)
|
|
348
|
+
builtin(left, :==, right)
|
|
349
|
+
elsif left_kind != right_kind
|
|
350
|
+
false
|
|
351
|
+
elsif %i[nil true false].include?(left_kind)
|
|
352
|
+
true
|
|
353
|
+
else
|
|
354
|
+
builtin(left, :==, right)
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def binary(left, operator, right)
|
|
359
|
+
if %i[== !=].include?(operator)
|
|
360
|
+
equal = primitive_equal?(left, right)
|
|
361
|
+
return operator == :== ? equal : !equal
|
|
362
|
+
end
|
|
363
|
+
if numeric?(left) && numeric?(right)
|
|
364
|
+
result = builtin(left, operator, right)
|
|
365
|
+
if kind(result) == :float && !builtin(result, :finite?)
|
|
366
|
+
raise SecurityError, 'Non-finite arithmetic result'
|
|
367
|
+
end
|
|
368
|
+
return checked(result)
|
|
369
|
+
end
|
|
370
|
+
if kind(left) == :string && kind(right) == :string && %i[+ < <= > >=].include?(operator)
|
|
371
|
+
if operator == :+ && builtin(left, :bytesize) + builtin(right, :bytesize) > MAX_STRING_BYTES
|
|
372
|
+
raise SecurityError, 'String computation exceeds expression limit'
|
|
373
|
+
end
|
|
374
|
+
return builtin(left, operator, right)
|
|
375
|
+
end
|
|
376
|
+
raise SecurityError, 'Operator requires builtin numbers or supported string operands'
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def display(value, budget, depth, nested = false)
|
|
380
|
+
step!(budget)
|
|
381
|
+
raise SecurityError, 'Log value nesting limit exceeded' if depth > SafeASTValidator::MAX_DEPTH
|
|
382
|
+
value = checked(value)
|
|
383
|
+
result = case kind(value)
|
|
384
|
+
when :string
|
|
385
|
+
nested ? builtin(value, :inspect) : value
|
|
386
|
+
when :integer, :float
|
|
387
|
+
builtin(value, :to_s)
|
|
388
|
+
when :symbol
|
|
389
|
+
builtin(value, :inspect)
|
|
390
|
+
when :nil, :true, :false
|
|
391
|
+
{ nil: 'nil', true: 'true', false: 'false' }.fetch(kind(value))
|
|
392
|
+
when :array
|
|
393
|
+
size = builtin(value, :length)
|
|
394
|
+
raise SecurityError, 'Log collection exceeds limit' if size > MAX_COLLECTION_ITEMS
|
|
395
|
+
parts = []
|
|
396
|
+
size.times do |index|
|
|
397
|
+
parts << display(builtin(value, :[], index), budget, depth + 1, true)
|
|
398
|
+
raise SecurityError, 'Log value exceeds limit' if parts.sum(&:bytesize) > MAX_STRING_BYTES
|
|
399
|
+
end
|
|
400
|
+
"[#{parts.join(', ')}]"
|
|
401
|
+
when :hash
|
|
402
|
+
raise SecurityError, 'Log collection exceeds limit' if builtin(value, :length) > MAX_COLLECTION_ITEMS
|
|
403
|
+
parts = []
|
|
404
|
+
builtin(value, :each_pair) do |key, item|
|
|
405
|
+
parts << "#{display(key, budget, depth + 1, true)}=>#{display(item, budget, depth + 1, true)}"
|
|
406
|
+
raise SecurityError, 'Log value exceeds limit' if parts.sum(&:bytesize) > MAX_STRING_BYTES
|
|
407
|
+
end
|
|
408
|
+
"{#{parts.join(', ')}}"
|
|
409
|
+
else
|
|
410
|
+
raise SecurityError, 'Log rendering requires builtin primitives or containers'
|
|
411
|
+
end
|
|
412
|
+
raise SecurityError, 'Log value exceeds limit' if result.bytesize > MAX_STRING_BYTES
|
|
413
|
+
result
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|