restless-sdk 0.1.0
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/CONFORMANCE.md +112 -0
- data/README.md +148 -0
- data/exe/restless-conformance +108 -0
- data/install.md +299 -0
- data/lib/restless/caches.rb +147 -0
- data/lib/restless/capture.rb +289 -0
- data/lib/restless/client.rb +111 -0
- data/lib/restless/conformance.rb +164 -0
- data/lib/restless/env.rb +82 -0
- data/lib/restless/fingerprint.rb +287 -0
- data/lib/restless/har.rb +113 -0
- data/lib/restless/injection.rb +99 -0
- data/lib/restless/mask.rb +57 -0
- data/lib/restless/rack.rb +374 -0
- data/lib/restless/redact.rb +342 -0
- data/lib/restless/request_id.rb +63 -0
- data/lib/restless/settings.rb +101 -0
- data/lib/restless/stack_frames.rb +146 -0
- data/lib/restless/text.rb +171 -0
- data/lib/restless/uploader.rb +319 -0
- data/lib/restless/version.rb +25 -0
- data/lib/restless.rb +57 -0
- data/spec/VECTORS_VERSION +1 -0
- data/spec/vectors/fingerprint.json +1119 -0
- data/spec/vectors/har.json +500 -0
- data/spec/vectors/mask.json +175 -0
- data/spec/vectors/recovery-slug.json +102 -0
- data/spec/vectors/redact.json +775 -0
- data/spec/vectors/request-id.json +120 -0
- metadata +82 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
require_relative "text"
|
|
7
|
+
|
|
8
|
+
module Restless
|
|
9
|
+
# CONTRACT.md section 4. Redaction for sensitive values in captured
|
|
10
|
+
# requests. Runs at the single choke point before anything enters the
|
|
11
|
+
# upload queue; no adapter may bypass it.
|
|
12
|
+
#
|
|
13
|
+
# Format: `<REDACTED:<length>[:<last4>]>`
|
|
14
|
+
module Redact
|
|
15
|
+
TAIL_MIN_LENGTH = 8
|
|
16
|
+
TAIL_CHARS = 4
|
|
17
|
+
|
|
18
|
+
# REDACT-011. Matched after REDACT-010 normalization.
|
|
19
|
+
DEFAULT_HEADER_DENYLIST = %w[
|
|
20
|
+
authorization
|
|
21
|
+
cookie
|
|
22
|
+
set-cookie
|
|
23
|
+
proxy-authorization
|
|
24
|
+
x-api-key
|
|
25
|
+
x-auth-token
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
# REDACT-012.
|
|
29
|
+
DEFAULT_BODY_KEY_DENYLIST = %w[
|
|
30
|
+
password
|
|
31
|
+
pass
|
|
32
|
+
pwd
|
|
33
|
+
token
|
|
34
|
+
secret
|
|
35
|
+
apikey
|
|
36
|
+
accesstoken
|
|
37
|
+
refreshtoken
|
|
38
|
+
idtoken
|
|
39
|
+
sessionid
|
|
40
|
+
ssn
|
|
41
|
+
creditcard
|
|
42
|
+
ccnumber
|
|
43
|
+
cvv
|
|
44
|
+
cvc
|
|
45
|
+
].freeze
|
|
46
|
+
|
|
47
|
+
# REDACT-013. Query params use the same list as body keys.
|
|
48
|
+
DEFAULT_QUERY_PARAM_DENYLIST = DEFAULT_BODY_KEY_DENYLIST
|
|
49
|
+
|
|
50
|
+
# REDACT-016. Headers that carry an HTTP auth-scheme prefix. For these the
|
|
51
|
+
# scheme word survives, so a debugger reading the dashboard can see at a
|
|
52
|
+
# glance whether the caller used Bearer, Basic or something custom.
|
|
53
|
+
SCHEME_PREFIX_HEADERS = Set.new(%w[authorization proxyauthorization]).freeze
|
|
54
|
+
|
|
55
|
+
# REDACT-030. 256 KiB, measured in UTF-8 bytes.
|
|
56
|
+
MAX_BODY_BYTES = 262_144
|
|
57
|
+
|
|
58
|
+
# PRIM-006. Hex digits are validated explicitly rather than handed to
|
|
59
|
+
# `String#to_i(16)`, which silently returns 0 for garbage.
|
|
60
|
+
HEX_PAIR_RE = /\A[0-9a-fA-F]{2}\z/.freeze
|
|
61
|
+
|
|
62
|
+
# REDACT-028. The RFC 3986 unreserved set, spelled out. No two languages'
|
|
63
|
+
# builtins agree: `encodeURIComponent` also leaves `!'()*` alone, Ruby's
|
|
64
|
+
# `CGI.escape` turns a space into `+` and escapes `~`, and Go's differs
|
|
65
|
+
# from both.
|
|
66
|
+
UNRESERVED_BYTES = begin
|
|
67
|
+
set = Array.new(256, false)
|
|
68
|
+
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
69
|
+
.each_byte { |b| set[b] = true }
|
|
70
|
+
set.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
module_function
|
|
74
|
+
|
|
75
|
+
# REDACT-001, REDACT-002. Length and tail are counted in CODE POINTS.
|
|
76
|
+
#
|
|
77
|
+
# An emoji is 1 code point, 2 UTF-16 code units and 4 UTF-8 bytes; a
|
|
78
|
+
# sentinel built from any other unit disagrees with every other SDK for
|
|
79
|
+
# the same secret, and a byte-wise tail slice produces an ill-formed
|
|
80
|
+
# string.
|
|
81
|
+
def redact_value(value)
|
|
82
|
+
points = value.chars
|
|
83
|
+
len = points.length
|
|
84
|
+
return "<REDACTED:#{len}>" if len < TAIL_MIN_LENGTH
|
|
85
|
+
|
|
86
|
+
"<REDACTED:#{len}:#{points.last(TAIL_CHARS).join}>"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# REDACT-010. Lowercase (PRIM-020, FULL Unicode) and drop every `-` and
|
|
90
|
+
# `_`, so `api_key`, `apiKey`, `API-KEY` and `APIKEY` all match `apikey`.
|
|
91
|
+
#
|
|
92
|
+
# Full Unicode, and that is a SECURITY requirement rather than a cosmetic
|
|
93
|
+
# one. This function is what decides whether a value gets redacted, so the
|
|
94
|
+
# safe direction is to fold MORE aggressively, never less. A header named
|
|
95
|
+
# `x-api-Key` or a body key `toKen` whose `K` is U+212A KELVIN SIGN
|
|
96
|
+
# full-lowercases to `x-api-key` / `token` -- both denylisted -- and the
|
|
97
|
+
# reference redacts them.
|
|
98
|
+
#
|
|
99
|
+
# An ASCII-only fold leaves the KELVIN spelling unmatched and ships the
|
|
100
|
+
# plaintext secret to the dashboard. Do not reintroduce it, and do not
|
|
101
|
+
# substitute `casefold`-style folding either (PRIM-020).
|
|
102
|
+
def normalize_name(name)
|
|
103
|
+
Text.full_lower(name).delete("-_")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_deny_set(defaults, extra)
|
|
107
|
+
# REDACT-014: defaults are ALWAYS applied. Configuration extends the
|
|
108
|
+
# lists; it can never shrink or replace them.
|
|
109
|
+
Set.new((defaults + Array(extra)).map { |n| normalize_name(n.to_s) })
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# REDACT-016. Split `Bearer <credential>` into its three parts, or nil
|
|
113
|
+
# when there is no scheme prefix to preserve.
|
|
114
|
+
#
|
|
115
|
+
# An explicit scan over CODE POINTS, never a regex. The obvious pattern
|
|
116
|
+
# `^(\S+)(\s+)(\S.*)$` is unportable and fails silently: JavaScript's `.`
|
|
117
|
+
# excludes CR/LS/PS so a credential containing a stray CR falls through to
|
|
118
|
+
# whole-value redaction, while a Ruby or Python `.` with DOTALL matches
|
|
119
|
+
# and preserves the scheme. Same header, same input, two captured values.
|
|
120
|
+
def split_auth_scheme(value)
|
|
121
|
+
chars = value.chars
|
|
122
|
+
i = 0
|
|
123
|
+
i += 1 while i < chars.length && !Text.ws?(chars[i])
|
|
124
|
+
# No whitespace at all, or the value starts with it: nothing to keep.
|
|
125
|
+
return nil if i.zero? || i >= chars.length
|
|
126
|
+
|
|
127
|
+
j = i
|
|
128
|
+
j += 1 while j < chars.length && Text.ws?(chars[j])
|
|
129
|
+
return nil if j >= chars.length # whitespace but no credential after it
|
|
130
|
+
|
|
131
|
+
{
|
|
132
|
+
scheme: chars[0, i].join,
|
|
133
|
+
gap: chars[i, j - i].join,
|
|
134
|
+
credential: chars[j..-1].join
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# REDACT-018, REDACT-019. Returns a NEW hash; never mutates the caller's.
|
|
139
|
+
def redact_headers(headers, extra = [])
|
|
140
|
+
deny = build_deny_set(DEFAULT_HEADER_DENYLIST, extra)
|
|
141
|
+
out = {}
|
|
142
|
+
headers.each do |key, value|
|
|
143
|
+
norm = normalize_name(key.to_s)
|
|
144
|
+
unless deny.include?(norm)
|
|
145
|
+
out[key] = value
|
|
146
|
+
next
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
if SCHEME_PREFIX_HEADERS.include?(norm)
|
|
150
|
+
split = split_auth_scheme(value.to_s)
|
|
151
|
+
if split
|
|
152
|
+
out[key] = "#{split[:scheme]}#{split[:gap]}" \
|
|
153
|
+
"#{redact_value(split[:credential])}"
|
|
154
|
+
next
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
out[key] = redact_value(value.to_s)
|
|
159
|
+
end
|
|
160
|
+
out
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# REDACT-028.
|
|
164
|
+
def percent_encode(value)
|
|
165
|
+
out = +""
|
|
166
|
+
Text.to_utf8(value).each_byte do |b|
|
|
167
|
+
out << (UNRESERVED_BYTES[b] ? b.chr : format("%%%02X", b))
|
|
168
|
+
end
|
|
169
|
+
out
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# REDACT-029. `+` is a space, `%XX` is a byte, a `%` not followed by two
|
|
173
|
+
# hex digits is literal, and invalid UTF-8 in the decoded bytes becomes
|
|
174
|
+
# U+FFFD instead of raising.
|
|
175
|
+
#
|
|
176
|
+
# Iterates CODE POINTS. Node's original indexed UTF-16 code units, walked
|
|
177
|
+
# into the middle of an astral character and handed each surrogate half to
|
|
178
|
+
# the encoder separately, turning one emoji into two U+FFFD -- so the
|
|
179
|
+
# sentinel reported the wrong length.
|
|
180
|
+
def percent_decode(value)
|
|
181
|
+
bytes = []
|
|
182
|
+
chars = value.chars
|
|
183
|
+
i = 0
|
|
184
|
+
len = chars.length
|
|
185
|
+
while i < len
|
|
186
|
+
ch = chars[i]
|
|
187
|
+
if ch == "+"
|
|
188
|
+
bytes << 0x20
|
|
189
|
+
i += 1
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
if ch == "%"
|
|
193
|
+
pair = chars[i + 1, 2]
|
|
194
|
+
if pair && pair.length == 2
|
|
195
|
+
hex = pair.join
|
|
196
|
+
if HEX_PAIR_RE.match?(hex)
|
|
197
|
+
bytes << hex.to_i(16)
|
|
198
|
+
i += 3
|
|
199
|
+
next
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
Text.to_utf8(ch).each_byte { |b| bytes << b }
|
|
204
|
+
i += 1
|
|
205
|
+
end
|
|
206
|
+
Text.bytes_to_utf8(bytes)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# REDACT-025..027. Rewrite denylisted query values IN PLACE.
|
|
210
|
+
#
|
|
211
|
+
# Scheme, host, port, path, parameter order, separators and fragment come
|
|
212
|
+
# through byte for byte; only the matched values change. Parsing and
|
|
213
|
+
# re-serializing through a URL library is wrong twice over: it loses
|
|
214
|
+
# repeated parameters (`?token=a&token=b` collapses to one) and it applies
|
|
215
|
+
# WHATWG normalization no other language reproduces.
|
|
216
|
+
def redact_url(url, extra = [])
|
|
217
|
+
deny = build_deny_set(DEFAULT_QUERY_PARAM_DENYLIST, extra)
|
|
218
|
+
|
|
219
|
+
q = url.index("?")
|
|
220
|
+
return url if q.nil?
|
|
221
|
+
|
|
222
|
+
head = url[0, q + 1]
|
|
223
|
+
rest = url[(q + 1)..-1] || ""
|
|
224
|
+
|
|
225
|
+
hash = rest.index("#")
|
|
226
|
+
query = hash.nil? ? rest : rest[0, hash]
|
|
227
|
+
tail = hash.nil? ? "" : rest[hash..-1]
|
|
228
|
+
return url if query.empty?
|
|
229
|
+
|
|
230
|
+
# `split("&", -1)`: without the negative limit Ruby drops trailing empty
|
|
231
|
+
# fields, so `?a=1&` would come back as `?a=1`.
|
|
232
|
+
parts = query.split("&", -1).map do |pair|
|
|
233
|
+
eq = pair.index("=")
|
|
234
|
+
next pair if eq.nil?
|
|
235
|
+
|
|
236
|
+
raw_key = pair[0, eq]
|
|
237
|
+
raw_val = pair[(eq + 1)..-1] || ""
|
|
238
|
+
next pair unless deny.include?(normalize_name(percent_decode(raw_key)))
|
|
239
|
+
|
|
240
|
+
"#{raw_key}=#{percent_encode(redact_value(percent_decode(raw_val)))}"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
head + parts.join("&") + tail
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# REDACT-022. Recursively redact denylisted keys in a parsed JSON value.
|
|
247
|
+
def redact_json_value(val, deny)
|
|
248
|
+
return val if val.nil?
|
|
249
|
+
return val.map { |v| redact_json_value(v, deny) } if val.is_a?(Array)
|
|
250
|
+
|
|
251
|
+
if val.is_a?(Hash)
|
|
252
|
+
out = {}
|
|
253
|
+
val.each do |k, v|
|
|
254
|
+
if deny.include?(normalize_name(k.to_s))
|
|
255
|
+
# REDACT-004: a non-string value becomes the bare `<REDACTED>`;
|
|
256
|
+
# null is left alone (there is nothing to leak, and nulling it out
|
|
257
|
+
# would lose schema information).
|
|
258
|
+
out[k] = if v.nil?
|
|
259
|
+
nil
|
|
260
|
+
elsif v.is_a?(String)
|
|
261
|
+
redact_value(v)
|
|
262
|
+
else
|
|
263
|
+
"<REDACTED>"
|
|
264
|
+
end
|
|
265
|
+
else
|
|
266
|
+
out[k] = redact_json_value(v, deny)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
return out
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
val
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# REDACT-021. Walk the PARSED value rather than pattern-matching the raw
|
|
276
|
+
# text, so every SDK reaches the same verdict with its own JSON parser and
|
|
277
|
+
# there is no regex dialect or escape decoding to get wrong.
|
|
278
|
+
def contains_denied_key?(val, deny)
|
|
279
|
+
return false if val.nil?
|
|
280
|
+
return val.any? { |v| contains_denied_key?(v, deny) } if val.is_a?(Array)
|
|
281
|
+
|
|
282
|
+
if val.is_a?(Hash)
|
|
283
|
+
val.each do |k, v|
|
|
284
|
+
return true if deny.include?(normalize_name(k.to_s))
|
|
285
|
+
return true if contains_denied_key?(v, deny)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
false
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# REDACT-020..024.
|
|
293
|
+
#
|
|
294
|
+
# When the body contains NOTHING to redact, the caller's original string
|
|
295
|
+
# is returned BYTE FOR BYTE rather than a re-serialized copy. That is a
|
|
296
|
+
# fidelity requirement, not an optimization: a parse/serialize round trip
|
|
297
|
+
# is lossy in every language, differently, and re-serialization is exactly
|
|
298
|
+
# where SDKs disagree.
|
|
299
|
+
def redact_body(body, content_type, extra = [])
|
|
300
|
+
return body if body.nil? || body.empty?
|
|
301
|
+
# REDACT-023: case-insensitive substring match on the content type.
|
|
302
|
+
return body unless Text.full_lower(content_type.to_s).include?("application/json")
|
|
303
|
+
|
|
304
|
+
begin
|
|
305
|
+
# max_nesting: false because the reference parser has no depth limit;
|
|
306
|
+
# a 200-deep body must not silently take a different branch here.
|
|
307
|
+
parsed = JSON.parse(body, max_nesting: false)
|
|
308
|
+
rescue StandardError
|
|
309
|
+
# REDACT-024: a body that does not parse passes through unchanged.
|
|
310
|
+
return body
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
deny = build_deny_set(DEFAULT_BODY_KEY_DENYLIST, extra)
|
|
314
|
+
return body unless contains_denied_key?(parsed, deny)
|
|
315
|
+
|
|
316
|
+
# PRIM-030 (compact), PRIM-031 (insertion order), PRIM-032 (literal
|
|
317
|
+
# UTF-8). Ruby's JSON encoder does all three by default.
|
|
318
|
+
JSON.generate(redact_json_value(parsed, deny))
|
|
319
|
+
rescue StandardError
|
|
320
|
+
body
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# REDACT-030..032. The cut is made at the byte limit and then backed off
|
|
324
|
+
# to the nearest character boundary, so the kept prefix is always a
|
|
325
|
+
# complete sequence of Unicode scalar values.
|
|
326
|
+
def truncate_body(body, max_bytes)
|
|
327
|
+
return body if body.nil? || body.empty?
|
|
328
|
+
|
|
329
|
+
buf = Text.to_utf8(body).dup.force_encoding(Encoding::ASCII_8BIT)
|
|
330
|
+
total = buf.bytesize
|
|
331
|
+
return body if total <= max_bytes
|
|
332
|
+
|
|
333
|
+
cut = max_bytes
|
|
334
|
+
cut = 0 if cut.negative?
|
|
335
|
+
# Walk back off any UTF-8 continuation byte (0b10xxxxxx).
|
|
336
|
+
cut -= 1 while cut.positive? && (buf.getbyte(cut) & 0xC0) == 0x80
|
|
337
|
+
|
|
338
|
+
kept = buf.byteslice(0, cut).force_encoding(Encoding::UTF_8)
|
|
339
|
+
"#{kept}\n[...TRUNCATED: original #{total} bytes]"
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Restless
|
|
6
|
+
# CONTRACT.md section 6.
|
|
7
|
+
module RequestId
|
|
8
|
+
UUID_RE = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/.freeze
|
|
9
|
+
|
|
10
|
+
# REQID-005. `/m` so `.` behaves like a JavaScript `.` cannot -- it is a
|
|
11
|
+
# superset, and the UUID test below is strict enough that the extra
|
|
12
|
+
# matches are all rejected anyway, which makes the two engines agree.
|
|
13
|
+
PREFIXED_RE = /\A[A-Za-z0-9]{1,7}-(.+)\z/m.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# REQID-001, REQID-002. An RFC 4122 v4 UUID from a CSPRNG, lowercase and
|
|
18
|
+
# hyphenated. Explicitly NOT time-ordered: request ids appear in
|
|
19
|
+
# user-visible URLs and logs and must not leak ordering or timing.
|
|
20
|
+
def new_request_id
|
|
21
|
+
SecureRandom.uuid
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# REQID-004. A configured prefix is for DISPLAY only; the raw UUID is what
|
|
25
|
+
# goes on the wire as `_id` (WIRE-011).
|
|
26
|
+
def format_request_id(raw_id, prefix = nil)
|
|
27
|
+
return raw_id if prefix.nil? || prefix.empty?
|
|
28
|
+
|
|
29
|
+
"#{prefix}-#{raw_id}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# REQID-005. Returns group 1 only when it is itself a valid UUID;
|
|
33
|
+
# otherwise the input comes back untouched.
|
|
34
|
+
def strip_request_id_prefix(request_id)
|
|
35
|
+
match = PREFIXED_RE.match(request_id)
|
|
36
|
+
return request_id unless match
|
|
37
|
+
return match[1] if UUID_RE.match?(match[1])
|
|
38
|
+
|
|
39
|
+
request_id
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def valid_request_id?(raw)
|
|
43
|
+
UUID_RE.match?(strip_request_id_prefix(raw))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# REQID-010, REQID-011. Exactly one id header per response.
|
|
47
|
+
#
|
|
48
|
+
# Emit `x-request-id` -- the header everyone knows -- carrying our freshly
|
|
49
|
+
# minted id. If the incoming request already carried one (a client, a
|
|
50
|
+
# reverse proxy, upstream middleware), fall back to `x-restless-id` so an
|
|
51
|
+
# existing chain is never clobbered.
|
|
52
|
+
#
|
|
53
|
+
# The literal `missing-key` is the setup CLI's signal that the server is
|
|
54
|
+
# running but the key never loaded, as opposed to the request silently
|
|
55
|
+
# dropping before upload.
|
|
56
|
+
def response_headers(our_id, incoming_headers, prefix = nil, has_api_key = true)
|
|
57
|
+
value = has_api_key ? format_request_id(our_id, prefix) : "missing-key"
|
|
58
|
+
incoming = incoming_headers["x-request-id"]
|
|
59
|
+
name = incoming.nil? || incoming == "" || incoming == false ? "x-request-id" : "x-restless-id"
|
|
60
|
+
{ name => value }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Restless
|
|
6
|
+
# CONTRACT.md section 12. `.restless/settings.json` is created and owned by
|
|
7
|
+
# the `api` CLI (`npx api setup`); the SDK consumes exactly two fields of it
|
|
8
|
+
# at runtime (CONFIG-015).
|
|
9
|
+
module Settings
|
|
10
|
+
class ConfigError < StandardError; end
|
|
11
|
+
|
|
12
|
+
@cache = nil
|
|
13
|
+
@loaded = false
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# CONFIG-010, CONFIG-011. Walk up from the working directory to the
|
|
18
|
+
# filesystem root, take the first hit, and read at most once per process
|
|
19
|
+
# -- including the negative result.
|
|
20
|
+
def load(start_dir = Dir.pwd)
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
return @cache if @loaded
|
|
23
|
+
|
|
24
|
+
@loaded = true
|
|
25
|
+
@cache = read_settings(start_dir)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Test-only. Do not call from production code.
|
|
30
|
+
def reset_cache!
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@loaded = false
|
|
33
|
+
@cache = nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# CONFIG-013, CONFIG-014. Returns {id:, name:, request_id_prefix:,
|
|
38
|
+
# redact:} or nil.
|
|
39
|
+
def resolve_api(settings, name = nil)
|
|
40
|
+
return nil if settings.nil?
|
|
41
|
+
|
|
42
|
+
apis = settings["apis"]
|
|
43
|
+
return nil unless apis.is_a?(Array) && !apis.empty?
|
|
44
|
+
|
|
45
|
+
if name && !name.empty?
|
|
46
|
+
match = apis.find { |a| a["name"] == name } || apis.find { |a| a["id"] == name }
|
|
47
|
+
unless match
|
|
48
|
+
raise ConfigError,
|
|
49
|
+
"restless-sdk: no API named #{name.inspect} in .restless/settings.json " \
|
|
50
|
+
"(found: #{apis.map { |a| a['name'] }.join(', ')})"
|
|
51
|
+
end
|
|
52
|
+
return entry(match)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
return entry(apis[0]) if apis.length == 1
|
|
56
|
+
|
|
57
|
+
# Guessing would silently apply the wrong redaction list.
|
|
58
|
+
raise ConfigError,
|
|
59
|
+
"restless-sdk: .restless/settings.json has multiple APIs " \
|
|
60
|
+
"(#{apis.map { |a| a['name'] }.join(', ')}) -- pass api: \"<name>\" to " \
|
|
61
|
+
"Restless::Client.new to pick one."
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def entry(api)
|
|
67
|
+
{
|
|
68
|
+
id: api["id"],
|
|
69
|
+
name: api["name"],
|
|
70
|
+
request_id_prefix: api["requestIdPrefix"],
|
|
71
|
+
redact: api["redact"].is_a?(Hash) ? api["redact"] : nil
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def read_settings(start_dir)
|
|
76
|
+
file = find_settings_file(start_dir)
|
|
77
|
+
return nil if file.nil?
|
|
78
|
+
|
|
79
|
+
# CONFIG-012: a missing or malformed file yields no configuration. It
|
|
80
|
+
# must not raise and must not prevent construction.
|
|
81
|
+
parsed = JSON.parse(File.read(file))
|
|
82
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def find_settings_file(start_dir)
|
|
88
|
+
dir = File.expand_path(start_dir)
|
|
89
|
+
loop do
|
|
90
|
+
candidate = File.join(dir, ".restless", "settings.json")
|
|
91
|
+
return candidate if File.file?(candidate)
|
|
92
|
+
|
|
93
|
+
parent = File.dirname(dir)
|
|
94
|
+
return nil if parent == dir
|
|
95
|
+
|
|
96
|
+
dir = parent
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbconfig"
|
|
4
|
+
|
|
5
|
+
require_relative "fingerprint"
|
|
6
|
+
|
|
7
|
+
module Restless
|
|
8
|
+
# CONTRACT.md FP-043, FP-044, FP-045. The Ruby dialect of stack parsing.
|
|
9
|
+
#
|
|
10
|
+
# FP-044 makes frame parsing and the skip list explicitly per-language: only
|
|
11
|
+
# the OUTPUT shape is contract surface. This module is therefore the one
|
|
12
|
+
# place in the SDK that knows what a Ruby backtrace looks like, and the
|
|
13
|
+
# shared conformance vectors deliberately do not cover it (FP-046); see
|
|
14
|
+
# `test/test_stack_frames.rb`.
|
|
15
|
+
module StackFrames
|
|
16
|
+
# Ruby <= 3.3: "path.rb:12:in `method'"
|
|
17
|
+
# Ruby >= 3.4: "path.rb:12:in 'Klass#method'"
|
|
18
|
+
FRAME_RE = /\A(.+):(\d+):in [`'](.+)'\z/.freeze
|
|
19
|
+
# Some frames carry no method label at all.
|
|
20
|
+
FRAME_NO_FN_RE = /\A(.+):(\d+)\z/.freeze
|
|
21
|
+
|
|
22
|
+
# `block (3 levels) in handler` -> `block in handler`. The nesting count
|
|
23
|
+
# is closer to a line number than to an identity: it moves when somebody
|
|
24
|
+
# wraps the throw site in one more `each`, which FP-041 says must not
|
|
25
|
+
# split a group.
|
|
26
|
+
BLOCK_LEVELS_RE = /\Ablock \(\d+ levels\) in /.freeze
|
|
27
|
+
|
|
28
|
+
# Ruby 3.4 changed backtrace method labels to carry the receiver:
|
|
29
|
+
# `detonate` became `Exploder.detonate`, and `run` became `Exploder#run`.
|
|
30
|
+
# Stripping it back off is not cosmetic. Without it the SAME crash in the
|
|
31
|
+
# SAME file fingerprints differently depending on which Ruby the service
|
|
32
|
+
# happens to run, so a routine runtime upgrade silently splits every
|
|
33
|
+
# existing 5xx group and orphans the Agent Recovery guidance attached to
|
|
34
|
+
# it. FP-041 forbids exactly that churn for line numbers; a runtime
|
|
35
|
+
# version is no different.
|
|
36
|
+
#
|
|
37
|
+
# Stripping rather than keeping, because that reproduces the label Ruby
|
|
38
|
+
# 3.3 and earlier already emit, so no stored key moves.
|
|
39
|
+
RECEIVER_RE = /\A(?:[A-Z]\w*(?:::[A-Z]\w*)*)[.#]/.freeze
|
|
40
|
+
|
|
41
|
+
# FP-044. The Ruby equivalent of `node_modules` / `node:internal` /
|
|
42
|
+
# `@restlessai/sdk`.
|
|
43
|
+
#
|
|
44
|
+
# Everything here is matched by FILE PATH, never by module or class name.
|
|
45
|
+
# A name check would also skip a customer's own `Restless`-flavoured code
|
|
46
|
+
# and, worse, would not skip this gem when it is vendored under a
|
|
47
|
+
# different constant. The SDK's own directory is resolved from `__dir__`,
|
|
48
|
+
# so it is correct however the gem was installed.
|
|
49
|
+
SDK_DIR = File.expand_path("..", __dir__).freeze
|
|
50
|
+
|
|
51
|
+
STDLIB_DIRS = [
|
|
52
|
+
RbConfig::CONFIG["rubylibdir"],
|
|
53
|
+
RbConfig::CONFIG["rubyarchdir"],
|
|
54
|
+
RbConfig::CONFIG["sitelibdir"],
|
|
55
|
+
RbConfig::CONFIG["vendorlibdir"]
|
|
56
|
+
].compact.reject(&:empty?).map { |d| File.expand_path(d) }.freeze
|
|
57
|
+
|
|
58
|
+
GEM_DIRS = begin
|
|
59
|
+
dirs = []
|
|
60
|
+
begin
|
|
61
|
+
dirs.concat(Array(Gem.path)) if defined?(Gem)
|
|
62
|
+
dirs << Gem.dir if defined?(Gem) && Gem.respond_to?(:dir)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
# Gem may not be loaded at all; the "/gems/" fallback below covers it.
|
|
65
|
+
end
|
|
66
|
+
dirs.compact.uniq.map { |d| File.expand_path(d) }.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
module_function
|
|
70
|
+
|
|
71
|
+
# FP-043. The frame NEAREST THE THROW SITE that is not vendor, runtime or
|
|
72
|
+
# SDK code.
|
|
73
|
+
#
|
|
74
|
+
# Ruby's `Exception#backtrace` is innermost-FIRST (index 0 is where the
|
|
75
|
+
# exception was raised), like a v8 `Error.stack` and unlike a Python
|
|
76
|
+
# traceback, so the walk goes FORWARDS. Implementing this positionally in
|
|
77
|
+
# the wrong direction returns the Rack entry point for every crash in the
|
|
78
|
+
# process, which collapses every 500 into one fingerprint group and
|
|
79
|
+
# defeats the strategy entirely. Verified empirically in
|
|
80
|
+
# `test/test_stack_frames.rb`, not assumed.
|
|
81
|
+
def top_user_frame(backtrace)
|
|
82
|
+
return nil if backtrace.nil?
|
|
83
|
+
|
|
84
|
+
Array(backtrace).each do |raw|
|
|
85
|
+
frame = parse_frame(raw.to_s)
|
|
86
|
+
next if frame.nil?
|
|
87
|
+
next if skip_path?(frame[:file])
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
file: Fingerprint.project_relative(frame[:file]),
|
|
91
|
+
fn: frame[:fn]
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def parse_frame(line)
|
|
98
|
+
if (m = FRAME_RE.match(line))
|
|
99
|
+
return { file: m[1], fn: normalize_fn(m[3]) }
|
|
100
|
+
end
|
|
101
|
+
if (m = FRAME_NO_FN_RE.match(line))
|
|
102
|
+
# FP-045.
|
|
103
|
+
return { file: m[1], fn: "anonymous" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def normalize_fn(name)
|
|
110
|
+
cleaned = name.sub(BLOCK_LEVELS_RE, "block in ")
|
|
111
|
+
cleaned = strip_receiver(cleaned)
|
|
112
|
+
cleaned.empty? ? "anonymous" : cleaned
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Applied after the block normalization, and to the method portion of a
|
|
116
|
+
# `block in X` label as well as a bare one, since 3.4 qualifies both.
|
|
117
|
+
def strip_receiver(name)
|
|
118
|
+
if name.start_with?("block in ")
|
|
119
|
+
"block in " + name.sub(/\Ablock in /, "").sub(RECEIVER_RE, "")
|
|
120
|
+
else
|
|
121
|
+
name.sub(RECEIVER_RE, "")
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def skip_path?(path)
|
|
126
|
+
return true if path.nil? || path.empty?
|
|
127
|
+
# Ruby 3.x synthesises frames like "<internal:kernel>:90:in `tap'".
|
|
128
|
+
return true if path.start_with?("<internal:")
|
|
129
|
+
return true if path.start_with?(SDK_DIR)
|
|
130
|
+
return true if path.include?("/gems/")
|
|
131
|
+
return true if STDLIB_DIRS.any? { |dir| path.start_with?(dir) }
|
|
132
|
+
return true if GEM_DIRS.any? { |dir| path.start_with?(dir) }
|
|
133
|
+
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convenience for adapters: fingerprint-ready frame from an exception.
|
|
138
|
+
def from_exception(error)
|
|
139
|
+
return nil unless error.respond_to?(:backtrace)
|
|
140
|
+
|
|
141
|
+
top_user_frame(error.backtrace)
|
|
142
|
+
rescue StandardError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|