hyperprobe-agent 1.2.27.pre.1 → 1.2.27.pre.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/README.md +316 -87
- data/lib/hyperprobe/agent.rb +243 -144
- data/lib/hyperprobe/core/broker.rb +58 -88
- data/lib/hyperprobe/core/evaluator.rb +358 -68
- data/lib/hyperprobe/core/lexical_scope.rb +222 -0
- data/lib/hyperprobe/core/logger.rb +33 -12
- data/lib/hyperprobe/core/monitoring_engine.rb +294 -120
- data/lib/hyperprobe/core/safe_ast_validator.rb +140 -61
- data/lib/hyperprobe/core/safety.rb +92 -15
- data/lib/hyperprobe/core/serializer.rb +354 -342
- data/lib/hyperprobe/core/transports/java_grpc.rb +57 -0
- data/lib/hyperprobe/lambda.rb +44 -42
- data/lib/hyperprobe/protos/agent_descriptor.rb +7 -0
- data/lib/hyperprobe/protos/java_messages.rb +199 -0
- data/lib/hyperprobe/protos.rb +7 -7
- data/lib/hyperprobe/railtie.rb +6 -0
- data/lib/hyperprobe/version.rb +1 -1
- data/lib/hyperprobe.rb +149 -20
- metadata +25 -9
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ripper'
|
|
4
|
+
|
|
5
|
+
module HyperProbe
|
|
6
|
+
module Core
|
|
7
|
+
# Installation-time analysis only. Retain the result on the probe, not a
|
|
8
|
+
# binding. nil means unknown: callers must omit locals, not fall back to all.
|
|
9
|
+
# Tokenless lines and lines shared by different scopes are intentionally
|
|
10
|
+
# unknown. Source must still match the code loaded by the application.
|
|
11
|
+
# Parsers with incomplete lambda ASTs (including JRuby 10.1.1's arrow
|
|
12
|
+
# lambdas) fail closed; ordinary lambda/proc blocks remain supported.
|
|
13
|
+
class LexicalScope
|
|
14
|
+
MAX_SOURCE_BYTES = 512 * 1024
|
|
15
|
+
MAX_NODES = 20_000
|
|
16
|
+
MAX_DEPTH = 128
|
|
17
|
+
CLASS = Object.instance_method(:class)
|
|
18
|
+
Scope = Struct.new(:parent, :declared, :assigned, :locals, :visible)
|
|
19
|
+
private_constant :CLASS, :Scope
|
|
20
|
+
|
|
21
|
+
def self.locals_for(path, line)
|
|
22
|
+
return nil unless CLASS.bind(path).call.equal?(String) && CLASS.bind(line).call.equal?(Integer)
|
|
23
|
+
return nil unless line.positive?
|
|
24
|
+
return nil unless File.file?(path)
|
|
25
|
+
|
|
26
|
+
source = File.open(path, 'rb') do |file|
|
|
27
|
+
return nil unless file.stat.file? && file.stat.size <= MAX_SOURCE_BYTES
|
|
28
|
+
file.read(MAX_SOURCE_BYTES + 1)
|
|
29
|
+
end
|
|
30
|
+
return nil if source.bytesize > MAX_SOURCE_BYTES
|
|
31
|
+
|
|
32
|
+
new(source).locals_for(line)
|
|
33
|
+
rescue StandardError, SystemStackError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(source)
|
|
38
|
+
tree = Ripper.sexp(source)
|
|
39
|
+
raise ArgumentError, 'Invalid Ruby source' unless tree
|
|
40
|
+
|
|
41
|
+
# Check iteratively before any recursive AST work (including positions).
|
|
42
|
+
stack = [[tree, 0]]
|
|
43
|
+
count = 0
|
|
44
|
+
until stack.empty?
|
|
45
|
+
node, depth = stack.pop
|
|
46
|
+
count += 1
|
|
47
|
+
raise ArgumentError, 'AST limit' if count > MAX_NODES || depth > MAX_DEPTH
|
|
48
|
+
node.each { |child| stack << [child, depth + 1] if child.is_a?(Array) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@lambda_headers = lambda_headers(source)
|
|
52
|
+
@lambda_index = 0
|
|
53
|
+
@scopes = []
|
|
54
|
+
@lines = {}
|
|
55
|
+
@unknown_lines = {}
|
|
56
|
+
walk(tree, scope(nil))
|
|
57
|
+
raise ArgumentError, 'Unmatched lambda' unless @lambda_index == @lambda_headers.length
|
|
58
|
+
|
|
59
|
+
# Ancestors are complete before descendants, including assignments
|
|
60
|
+
# textually after a block. Such names are conservatively kept private.
|
|
61
|
+
@scopes.each do |current|
|
|
62
|
+
ancestors = current.parent ? current.parent.visible : []
|
|
63
|
+
current.locals = (current.declared + (current.assigned - ancestors)).uniq.freeze
|
|
64
|
+
current.visible = (ancestors + current.locals).uniq
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def locals_for(line)
|
|
69
|
+
return nil if @unknown_lines[line]
|
|
70
|
+
|
|
71
|
+
owners = @lines[line]
|
|
72
|
+
owners && owners.length == 1 ? owners.first.locals.dup : nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def scope(parent)
|
|
78
|
+
current = Scope.new(parent, [], [], nil, nil)
|
|
79
|
+
@scopes << current
|
|
80
|
+
current
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def walk(node, current)
|
|
84
|
+
return unless node.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
kind = node[0]
|
|
87
|
+
if kind.is_a?(Symbol) && kind.to_s.start_with?('@')
|
|
88
|
+
first = node[2][0]
|
|
89
|
+
last = first + node[1].count("\n")
|
|
90
|
+
last -= 1 if node[1].end_with?("\n")
|
|
91
|
+
(first..last).each do |line|
|
|
92
|
+
owners = (@lines[line] ||= [])
|
|
93
|
+
owners << current unless owners.any? { |owner| owner.equal?(current) }
|
|
94
|
+
end
|
|
95
|
+
return
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
case kind
|
|
99
|
+
when :def, :defs
|
|
100
|
+
# A line alone cannot distinguish definition from default evaluation.
|
|
101
|
+
header, params, body = kind == :def ? [node[1..2], node[2], node[3]] : [node[1..4], node[4], node[5]]
|
|
102
|
+
token_positions(header).each { |position| @unknown_lines[position[0]] = true }
|
|
103
|
+
method_scope = scope(nil)
|
|
104
|
+
parameters(params, method_scope)
|
|
105
|
+
walk(node[1], current) if kind == :defs
|
|
106
|
+
walk(params, method_scope)
|
|
107
|
+
walk(body, method_scope)
|
|
108
|
+
when :class, :module, :sclass
|
|
109
|
+
inner = scope(nil)
|
|
110
|
+
token_positions(node[1...-1]).each { |position| @unknown_lines[position[0]] = true }
|
|
111
|
+
walk(node[1...-1], current)
|
|
112
|
+
walk(node[-1], inner)
|
|
113
|
+
when :do_block, :brace_block, :lambda
|
|
114
|
+
inner = scope(current)
|
|
115
|
+
parameters(node[1], inner)
|
|
116
|
+
if kind == :lambda
|
|
117
|
+
raise ArgumentError, 'Incomplete lambda AST' unless node[1].is_a?(Array)
|
|
118
|
+
|
|
119
|
+
header = @lambda_headers.fetch(@lambda_index)
|
|
120
|
+
@lambda_index += 1
|
|
121
|
+
inner.declared.concat(header[:locals])
|
|
122
|
+
# Ripper leaves lambda semicolon locals out of sexp entirely.
|
|
123
|
+
# Validate the association when there are positioned parameters.
|
|
124
|
+
positions = token_positions(node[1])
|
|
125
|
+
unless positions.all? { |position| (position <=> header[:start]) >= 0 && (position <=> header[:finish]) <= 0 }
|
|
126
|
+
raise ArgumentError, 'Ambiguous lambda header'
|
|
127
|
+
end
|
|
128
|
+
unless token_positions(node[2]).all? { |position| (position <=> header[:finish]) >= 0 }
|
|
129
|
+
raise ArgumentError, 'Ambiguous lambda body'
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
walk(node[1], inner)
|
|
133
|
+
walk(node[2], inner)
|
|
134
|
+
when :var_field
|
|
135
|
+
current.assigned << node[1][1].to_sym if node[1] && node[1][0] == :@ident
|
|
136
|
+
node.each { |child| walk(child, current) }
|
|
137
|
+
when :BEGIN, :END
|
|
138
|
+
raise ArgumentError, 'Unsupported execution scope'
|
|
139
|
+
else
|
|
140
|
+
node.each { |child| walk(child, current) }
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def parameters(node, current)
|
|
145
|
+
return unless node.is_a?(Array)
|
|
146
|
+
|
|
147
|
+
case node[0]
|
|
148
|
+
when :paren
|
|
149
|
+
parameters(node[1], current)
|
|
150
|
+
when :block_var
|
|
151
|
+
parameters(node[1], current)
|
|
152
|
+
declare(node[2], current)
|
|
153
|
+
when :params
|
|
154
|
+
declare(node[1], current)
|
|
155
|
+
(node[2] || []).each { |pair| declare(pair[0], current) }
|
|
156
|
+
declare(node[3], current)
|
|
157
|
+
declare(node[4], current)
|
|
158
|
+
(node[5] || []).each { |pair| declare(pair[0], current) }
|
|
159
|
+
declare(node[6], current)
|
|
160
|
+
declare(node[7], current)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def declare(node, current)
|
|
165
|
+
return unless node.is_a?(Array)
|
|
166
|
+
|
|
167
|
+
if node[0] == :@ident || node[0] == :@label
|
|
168
|
+
current.declared << node[1].delete_suffix(':').to_sym
|
|
169
|
+
else
|
|
170
|
+
node.each { |child| declare(child, current) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def token_positions(node)
|
|
175
|
+
return [] unless node.is_a?(Array)
|
|
176
|
+
return [node[2]] if node[0].is_a?(Symbol) && node[0].to_s.start_with?('@')
|
|
177
|
+
|
|
178
|
+
node.flat_map { |child| token_positions(child) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def lambda_headers(source)
|
|
182
|
+
tokens = Ripper.lex(source)
|
|
183
|
+
raise ArgumentError, 'Token limit' if tokens.length > MAX_NODES
|
|
184
|
+
|
|
185
|
+
headers = []
|
|
186
|
+
work = 0
|
|
187
|
+
tokens.each_with_index do |token, index|
|
|
188
|
+
next unless token[1] == :on_tlambda
|
|
189
|
+
|
|
190
|
+
header = { start: token[0], finish: token[0], locals: [] }
|
|
191
|
+
depth = 0
|
|
192
|
+
shadow = false
|
|
193
|
+
cursor = index + 1
|
|
194
|
+
while cursor < tokens.length
|
|
195
|
+
work += 1
|
|
196
|
+
raise ArgumentError, 'Lambda header limit' if work > MAX_NODES
|
|
197
|
+
position, type, text = tokens[cursor]
|
|
198
|
+
cursor += 1
|
|
199
|
+
header[:finish] = position
|
|
200
|
+
break if depth.zero? && (type == :on_tlambeg || (type == :on_kw && text == 'do'))
|
|
201
|
+
|
|
202
|
+
if shadow
|
|
203
|
+
if type == :on_rparen && depth == 1
|
|
204
|
+
shadow = false
|
|
205
|
+
elsif !%i[on_ident on_comma on_sp on_nl on_ignored_nl on_comment].include?(type)
|
|
206
|
+
raise ArgumentError, 'Ambiguous lambda shadow declaration'
|
|
207
|
+
end
|
|
208
|
+
header[:locals] << text.to_sym if type == :on_ident
|
|
209
|
+
end
|
|
210
|
+
depth += 1 if type == :on_lparen
|
|
211
|
+
depth -= 1 if type == :on_rparen
|
|
212
|
+
shadow = true if depth == 1 && type == :on_semicolon
|
|
213
|
+
end
|
|
214
|
+
headers << header
|
|
215
|
+
end
|
|
216
|
+
headers
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
private_class_method :new
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -98,26 +98,27 @@ module HyperProbe
|
|
|
98
98
|
def warn(message)
|
|
99
99
|
timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
|
|
100
100
|
formatted = "#{timestamp} \e[33m[HyperProbe WARN]\e[0m [#{@namespace}] #{message}"
|
|
101
|
-
$stdout
|
|
102
|
-
$stdout.flush rescue nil
|
|
101
|
+
emit($stdout, formatted)
|
|
103
102
|
end
|
|
104
103
|
|
|
105
104
|
def error(message, exception = nil)
|
|
106
105
|
timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
|
|
107
106
|
formatted = "#{timestamp} \e[31m[HyperProbe ERROR]\e[0m [#{@namespace}] #{message}"
|
|
108
|
-
$stderr
|
|
107
|
+
emit($stderr, formatted)
|
|
109
108
|
if exception
|
|
110
|
-
|
|
111
|
-
$stderr.
|
|
109
|
+
type = Kernel.instance_method(:class).bind(exception).call
|
|
110
|
+
emit($stderr, " #{Module.instance_method(:name).bind(type).call}")
|
|
112
111
|
end
|
|
113
|
-
|
|
112
|
+
rescue SignalException, SystemExit
|
|
113
|
+
raise
|
|
114
|
+
rescue Exception
|
|
115
|
+
nil
|
|
114
116
|
end
|
|
115
117
|
|
|
116
118
|
def force_info(message)
|
|
117
119
|
timestamp = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S.%L')
|
|
118
120
|
formatted = "#{timestamp} \e[34mHyperProbe\e[0m -- [#{@namespace}] #{message}"
|
|
119
|
-
$stdout
|
|
120
|
-
$stdout.flush rescue nil
|
|
121
|
+
emit($stdout, formatted)
|
|
121
122
|
end
|
|
122
123
|
|
|
123
124
|
alias forceInfo force_info
|
|
@@ -134,10 +135,13 @@ module HyperProbe
|
|
|
134
135
|
now = monotonic_ms
|
|
135
136
|
delta_str = ''
|
|
136
137
|
|
|
137
|
-
@mutex.
|
|
138
|
+
return unless @mutex.try_lock
|
|
139
|
+
begin
|
|
138
140
|
delta = now - @last_log_time_ms
|
|
139
141
|
delta_str = " \e[#{@color}m+#{delta}ms\e[0m" if delta > 0
|
|
140
142
|
@last_log_time_ms = now
|
|
143
|
+
ensure
|
|
144
|
+
@mutex.unlock
|
|
141
145
|
end
|
|
142
146
|
|
|
143
147
|
prefix = if colors_enabled?(stream)
|
|
@@ -146,8 +150,25 @@ module HyperProbe
|
|
|
146
150
|
@namespace
|
|
147
151
|
end
|
|
148
152
|
|
|
149
|
-
stream
|
|
150
|
-
|
|
153
|
+
emit(stream, " #{prefix} \e[90m#{message}\e[0m#{delta_str}")
|
|
154
|
+
rescue SignalException, SystemExit
|
|
155
|
+
raise
|
|
156
|
+
rescue Exception
|
|
157
|
+
nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def emit(stream, message)
|
|
161
|
+
line = "#{message}\n".byteslice(0, 4096)
|
|
162
|
+
if IO === stream
|
|
163
|
+
IO.instance_method(:write_nonblock).bind(stream).call(line, exception: false)
|
|
164
|
+
elsif defined?(StringIO) && StringIO === stream
|
|
165
|
+
StringIO.instance_method(:write).bind(stream).call(line)
|
|
166
|
+
end
|
|
167
|
+
rescue SignalException, SystemExit
|
|
168
|
+
raise
|
|
169
|
+
rescue Exception
|
|
170
|
+
# Never block or call arbitrary application output adapters.
|
|
171
|
+
nil
|
|
151
172
|
end
|
|
152
173
|
|
|
153
174
|
def select_namespace_color(ns)
|
|
@@ -164,7 +185,7 @@ module HyperProbe
|
|
|
164
185
|
return !DISABLED_COLOR_VALUES.include?(configured.strip.downcase)
|
|
165
186
|
end
|
|
166
187
|
|
|
167
|
-
stream.
|
|
188
|
+
IO === stream && IO.instance_method(:tty?).bind(stream).call
|
|
168
189
|
rescue StandardError
|
|
169
190
|
false
|
|
170
191
|
end
|