dregs 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/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +247 -0
- data/SECURITY.md +36 -0
- data/lib/dregs/client.rb +402 -0
- data/lib/dregs/errors.rb +143 -0
- data/lib/dregs/identities.rb +91 -0
- data/lib/dregs/models.rb +520 -0
- data/lib/dregs/version.rb +6 -0
- data/lib/dregs/webhooks.rb +144 -0
- data/lib/dregs.rb +38 -0
- data/sig/dregs.rbs +286 -0
- metadata +64 -0
data/lib/dregs/models.rb
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Dregs
|
|
6
|
+
# The four categories Dregs scores an identity in.
|
|
7
|
+
#
|
|
8
|
+
# The API spells them in upper case (+"HUMANITY"+); this SDK hands you lower-case symbols
|
|
9
|
+
# (+:humanity+) and keeps the string Dregs sent in the model's +raw+. A category this release
|
|
10
|
+
# predates parses as +nil+ rather than raising, so a future fifth category cannot break an
|
|
11
|
+
# otherwise good response.
|
|
12
|
+
module Category
|
|
13
|
+
HUMANITY = :humanity
|
|
14
|
+
AUTHENTICITY = :authenticity
|
|
15
|
+
UNIQUENESS = :uniqueness
|
|
16
|
+
BEHAVIOR = :behavior
|
|
17
|
+
|
|
18
|
+
# Every category, in the order the dashboard shows them.
|
|
19
|
+
ALL = [HUMANITY, AUTHENTICITY, UNIQUENESS, BEHAVIOR].freeze
|
|
20
|
+
|
|
21
|
+
# Turns the API's spelling of a category into a symbol.
|
|
22
|
+
#
|
|
23
|
+
# @param value [String, Symbol, nil] the +category+ field from a response
|
|
24
|
+
# @return [Symbol, nil] the matching category, or nil when it is not one this release knows
|
|
25
|
+
def self.parse(value)
|
|
26
|
+
return nil unless value.is_a?(String) || value.is_a?(Symbol)
|
|
27
|
+
|
|
28
|
+
symbol = value.to_s.downcase.to_sym
|
|
29
|
+
|
|
30
|
+
ALL.include?(symbol) ? symbol : nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parsing helpers shared by the models.
|
|
35
|
+
#
|
|
36
|
+
# Parsing is deliberately lenient: a missing field becomes +nil+ rather than an error, because an
|
|
37
|
+
# SDK that refuses to parse a response it half-understands is worse than one that hands back what
|
|
38
|
+
# it got.
|
|
39
|
+
#
|
|
40
|
+
# @api private
|
|
41
|
+
module Parsing
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# @param value [Object] an ISO-8601 string, or anything else
|
|
45
|
+
# @return [Time, nil] the parsed time in UTC, or nil when it was unreadable
|
|
46
|
+
def time(value)
|
|
47
|
+
return nil unless value.is_a?(String) && !value.empty?
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
Time.iso8601(value).getutc
|
|
51
|
+
rescue ArgumentError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param value [Object]
|
|
57
|
+
# @return [Hash] the value when it is a hash, otherwise an empty hash
|
|
58
|
+
def hash_or_empty(value)
|
|
59
|
+
value.is_a?(Hash) ? value : {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param value [Object]
|
|
63
|
+
# @return [Array] the value when it is an array, otherwise an empty array
|
|
64
|
+
def array_or_empty(value)
|
|
65
|
+
value.is_a?(Array) ? value : []
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The outcome of a {Client#track} call.
|
|
70
|
+
#
|
|
71
|
+
# @!attribute [r] status
|
|
72
|
+
# @return [String, nil] the status Dregs reported, normally +"success"+
|
|
73
|
+
# @!attribute [r] id
|
|
74
|
+
# @return [String, nil] the event's identifier, either the one you supplied or one Dregs
|
|
75
|
+
# generated; nil when the event was not recorded
|
|
76
|
+
# @!attribute [r] fingerprint
|
|
77
|
+
# @return [String, nil] the device fingerprint Dregs resolved, for events that carried a device
|
|
78
|
+
# signature; server-side events do not, so this is normally nil
|
|
79
|
+
# @!attribute [r] raw
|
|
80
|
+
# @return [Hash] the response body as received
|
|
81
|
+
class TrackResult
|
|
82
|
+
attr_reader :status, :id, :fingerprint, :raw
|
|
83
|
+
|
|
84
|
+
# @api private
|
|
85
|
+
def initialize(status:, id:, fingerprint:, raw: {})
|
|
86
|
+
@status = status
|
|
87
|
+
@id = id
|
|
88
|
+
@fingerprint = fingerprint
|
|
89
|
+
@raw = raw
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Whether Dregs recorded the event.
|
|
93
|
+
#
|
|
94
|
+
# This is false in the uncommon case where Dregs accepts the request without recording an
|
|
95
|
+
# event. A server-side integration holding a valid secret key should not normally see it, so it
|
|
96
|
+
# is worth a log line if you do. Ingestion failures that are yours to act on (a bad request, an
|
|
97
|
+
# unknown key, an exhausted quota, a rate limit) raise instead of landing here.
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean]
|
|
100
|
+
def accepted?
|
|
101
|
+
!id.nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
def self.from_api(payload)
|
|
106
|
+
payload = Parsing.hash_or_empty(payload)
|
|
107
|
+
|
|
108
|
+
new(
|
|
109
|
+
status: payload["status"],
|
|
110
|
+
id: payload["id"],
|
|
111
|
+
fingerprint: payload["fingerprint"],
|
|
112
|
+
raw: payload
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A label Dregs applied to an identity, from an analyzer or a badge rule.
|
|
118
|
+
#
|
|
119
|
+
# @!attribute [r] slug
|
|
120
|
+
# @return [String, nil] the stable identifier, such as +"behavior.account-takeover-signal"+
|
|
121
|
+
# @!attribute [r] name
|
|
122
|
+
# @return [String, nil] the human-readable name shown in the dashboard
|
|
123
|
+
# @!attribute [r] type
|
|
124
|
+
# @return [String, nil] the badge's severity or kind
|
|
125
|
+
# @!attribute [r] explanation
|
|
126
|
+
# @return [String, nil] why the badge was applied
|
|
127
|
+
# @!attribute [r] metadata
|
|
128
|
+
# @return [Hash] the details behind it
|
|
129
|
+
# @!attribute [r] raw
|
|
130
|
+
# @return [Hash] the badge as received
|
|
131
|
+
class Badge
|
|
132
|
+
attr_reader :slug, :name, :type, :explanation, :metadata, :raw
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
def initialize(slug:, name:, type:, explanation:, metadata: {}, raw: {})
|
|
136
|
+
@slug = slug
|
|
137
|
+
@name = name
|
|
138
|
+
@type = type
|
|
139
|
+
@explanation = explanation
|
|
140
|
+
@metadata = metadata
|
|
141
|
+
@raw = raw
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @api private
|
|
145
|
+
def self.from_api(payload)
|
|
146
|
+
payload = Parsing.hash_or_empty(payload)
|
|
147
|
+
|
|
148
|
+
new(
|
|
149
|
+
slug: payload["slug"],
|
|
150
|
+
name: payload["name"],
|
|
151
|
+
type: payload["type"],
|
|
152
|
+
explanation: payload["explanation"],
|
|
153
|
+
metadata: Parsing.hash_or_empty(payload["metadata"]),
|
|
154
|
+
raw: payload
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One analyzer's finding, and the reasoning behind a slice of a score.
|
|
160
|
+
#
|
|
161
|
+
# @!attribute [r] category
|
|
162
|
+
# @return [Symbol, nil] the category the observation contributes to
|
|
163
|
+
# @!attribute [r] id
|
|
164
|
+
# @return [String, nil] the analyzer's identifier, such as +"humanity.user-agent"+
|
|
165
|
+
# @!attribute [r] label
|
|
166
|
+
# @return [String, nil] a human-readable name for the analyzer
|
|
167
|
+
# @!attribute [r] explanation
|
|
168
|
+
# @return [String, nil] a sentence describing what the analyzer found
|
|
169
|
+
# @!attribute [r] value
|
|
170
|
+
# @return [Float, nil] 0.0 for entirely suspicious, 1.0 for entirely legitimate
|
|
171
|
+
# @!attribute [r] confidence
|
|
172
|
+
# @return [Float, nil] how sure the analyzer is, from 0.0 to 1.0
|
|
173
|
+
# @!attribute [r] weight
|
|
174
|
+
# @return [Float, nil] how heavily this observation counts toward the category score
|
|
175
|
+
# @!attribute [r] metadata
|
|
176
|
+
# @return [Hash] the counts and details behind the finding
|
|
177
|
+
# @!attribute [r] raw
|
|
178
|
+
# @return [Hash] the observation as received
|
|
179
|
+
class Observation
|
|
180
|
+
attr_reader :category, :id, :label, :explanation, :value, :confidence, :weight, :metadata, :raw
|
|
181
|
+
|
|
182
|
+
# @api private
|
|
183
|
+
def initialize(category:, id:, label:, explanation:, value:, confidence:, weight:, metadata: {}, raw: {})
|
|
184
|
+
@category = category
|
|
185
|
+
@id = id
|
|
186
|
+
@label = label
|
|
187
|
+
@explanation = explanation
|
|
188
|
+
@value = value
|
|
189
|
+
@confidence = confidence
|
|
190
|
+
@weight = weight
|
|
191
|
+
@metadata = metadata
|
|
192
|
+
@raw = raw
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# @api private
|
|
196
|
+
def self.from_api(payload)
|
|
197
|
+
payload = Parsing.hash_or_empty(payload)
|
|
198
|
+
|
|
199
|
+
new(
|
|
200
|
+
category: Category.parse(payload["category"]),
|
|
201
|
+
id: payload["id"],
|
|
202
|
+
label: payload["label"],
|
|
203
|
+
explanation: payload["explanation"],
|
|
204
|
+
value: payload["value"],
|
|
205
|
+
confidence: payload["confidence"],
|
|
206
|
+
weight: payload["weight"],
|
|
207
|
+
metadata: Parsing.hash_or_empty(payload["metadata"]),
|
|
208
|
+
raw: payload
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# One category's score.
|
|
214
|
+
#
|
|
215
|
+
# @!attribute [r] category
|
|
216
|
+
# @return [Symbol, nil] the category scored
|
|
217
|
+
# @!attribute [r] value
|
|
218
|
+
# @return [Integer, nil] a score from 0 (worst) to 100 (best)
|
|
219
|
+
# @!attribute [r] observations
|
|
220
|
+
# @return [Array<Observation>] the observations behind the score, empty on the result of
|
|
221
|
+
# {Identities#scores}, which reports the scores alone; the observations come from
|
|
222
|
+
# {Identities#analysis}
|
|
223
|
+
# @!attribute [r] raw
|
|
224
|
+
# @return [Hash] the score as received
|
|
225
|
+
class Score
|
|
226
|
+
attr_reader :category, :value, :observations, :raw
|
|
227
|
+
|
|
228
|
+
# @api private
|
|
229
|
+
def initialize(category:, value:, observations: [], raw: {})
|
|
230
|
+
@category = category
|
|
231
|
+
@value = value
|
|
232
|
+
@observations = observations
|
|
233
|
+
@raw = raw
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# @api private
|
|
237
|
+
def self.from_api(payload)
|
|
238
|
+
payload = Parsing.hash_or_empty(payload)
|
|
239
|
+
|
|
240
|
+
new(
|
|
241
|
+
category: Category.parse(payload["category"]),
|
|
242
|
+
value: payload["value"],
|
|
243
|
+
observations: Parsing.array_or_empty(payload["observations"]).map { |o| Observation.from_api(o) },
|
|
244
|
+
raw: payload
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# An identity's four category scores.
|
|
250
|
+
#
|
|
251
|
+
# Enumerable over {Score}, and also offering the four categories by name:
|
|
252
|
+
#
|
|
253
|
+
# scores = client.identities.scores("user_12345")
|
|
254
|
+
#
|
|
255
|
+
# hold_for_review if scores.authenticity && scores.authenticity < 40
|
|
256
|
+
#
|
|
257
|
+
# A category Dregs has not scored yet is absent from the collection, and its named accessor
|
|
258
|
+
# returns nil. A brand-new identity therefore comes back empty.
|
|
259
|
+
class Scores
|
|
260
|
+
include Enumerable
|
|
261
|
+
|
|
262
|
+
# @return [Array<Score>] the scores Dregs has computed, in the order it sent them
|
|
263
|
+
attr_reader :to_a
|
|
264
|
+
|
|
265
|
+
# @api private
|
|
266
|
+
def initialize(scores = [])
|
|
267
|
+
@to_a = scores.freeze
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @yieldparam score [Score]
|
|
271
|
+
# @return [Enumerator, self]
|
|
272
|
+
def each(&block)
|
|
273
|
+
return to_enum(:each) unless block
|
|
274
|
+
|
|
275
|
+
to_a.each(&block)
|
|
276
|
+
|
|
277
|
+
self
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @return [Integer] how many categories have been scored
|
|
281
|
+
def size
|
|
282
|
+
to_a.size
|
|
283
|
+
end
|
|
284
|
+
alias length size
|
|
285
|
+
|
|
286
|
+
# @return [Boolean] whether no category has been scored yet
|
|
287
|
+
def empty?
|
|
288
|
+
to_a.empty?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# @param index [Integer]
|
|
292
|
+
# @return [Score, nil]
|
|
293
|
+
def [](index)
|
|
294
|
+
to_a[index]
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Returns the {Score} for a category, or nil when it has not been scored.
|
|
298
|
+
#
|
|
299
|
+
# @param category [Symbol] one of {Category::ALL}
|
|
300
|
+
# @return [Score, nil]
|
|
301
|
+
def get(category)
|
|
302
|
+
to_a.find { |score| score.category == category }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# How likely it is that a person, rather than a script, is behind the account.
|
|
306
|
+
#
|
|
307
|
+
# @return [Integer, nil]
|
|
308
|
+
def humanity
|
|
309
|
+
value_for(Category::HUMANITY)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# How genuine the details on the account look.
|
|
313
|
+
#
|
|
314
|
+
# @return [Integer, nil]
|
|
315
|
+
def authenticity
|
|
316
|
+
value_for(Category::AUTHENTICITY)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# How distinct the account is from others in the same tenant.
|
|
320
|
+
#
|
|
321
|
+
# @return [Integer, nil]
|
|
322
|
+
def uniqueness
|
|
323
|
+
value_for(Category::UNIQUENESS)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# How ordinary the account's activity looks.
|
|
327
|
+
#
|
|
328
|
+
# @return [Integer, nil]
|
|
329
|
+
def behavior
|
|
330
|
+
value_for(Category::BEHAVIOR)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# @api private
|
|
334
|
+
def self.from_api(payload)
|
|
335
|
+
new(Parsing.array_or_empty(payload).map { |score| Score.from_api(score) })
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
private
|
|
339
|
+
|
|
340
|
+
def value_for(category)
|
|
341
|
+
get(category)&.value
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# A user Dregs is tracking, and their current scores.
|
|
346
|
+
#
|
|
347
|
+
# +id+ is your own identifier for the user, the one you pass to {Client#track} and to
|
|
348
|
+
# +dregs.identify()+ in the browser tracker, not an internal Dregs id.
|
|
349
|
+
#
|
|
350
|
+
# @!attribute [r] id
|
|
351
|
+
# @return [String, nil] your identifier for the user
|
|
352
|
+
# @!attribute [r] display_name
|
|
353
|
+
# @return [String, nil] the name Dregs resolved from the attributes you have sent
|
|
354
|
+
# @!attribute [r] display_email
|
|
355
|
+
# @return [String, nil] the email Dregs resolved
|
|
356
|
+
# @!attribute [r] display_username
|
|
357
|
+
# @return [String, nil] the username Dregs resolved
|
|
358
|
+
# @!attribute [r] humanity_score
|
|
359
|
+
# @return [Integer, nil]
|
|
360
|
+
# @!attribute [r] authenticity_score
|
|
361
|
+
# @return [Integer, nil]
|
|
362
|
+
# @!attribute [r] uniqueness_score
|
|
363
|
+
# @return [Integer, nil]
|
|
364
|
+
# @!attribute [r] behavior_score
|
|
365
|
+
# @return [Integer, nil]
|
|
366
|
+
# @!attribute [r] created_at
|
|
367
|
+
# @return [Time, nil] when Dregs first saw this user
|
|
368
|
+
# @!attribute [r] updated_at
|
|
369
|
+
# @return [Time, nil]
|
|
370
|
+
# @!attribute [r] last_tracked_at
|
|
371
|
+
# @return [Time, nil] the most recent event
|
|
372
|
+
# @!attribute [r] last_scored_at
|
|
373
|
+
# @return [Time, nil] the most recent analysis cycle
|
|
374
|
+
# @!attribute [r] disregarded
|
|
375
|
+
# @return [Boolean] whether the identity is excluded from fraud analysis
|
|
376
|
+
# @!attribute [r] badges
|
|
377
|
+
# @return [Array<Badge>]
|
|
378
|
+
# @!attribute [r] data
|
|
379
|
+
# @return [Hash] every attribute you have sent for this user
|
|
380
|
+
# @!attribute [r] raw
|
|
381
|
+
# @return [Hash] the identity as received
|
|
382
|
+
class Identity
|
|
383
|
+
attr_reader :id, :display_name, :display_email, :display_username, :humanity_score,
|
|
384
|
+
:authenticity_score, :uniqueness_score, :behavior_score, :created_at, :updated_at,
|
|
385
|
+
:last_tracked_at, :last_scored_at, :disregarded, :badges, :data, :raw
|
|
386
|
+
|
|
387
|
+
# @api private
|
|
388
|
+
def initialize(attributes)
|
|
389
|
+
@id = attributes[:id]
|
|
390
|
+
@display_name = attributes[:display_name]
|
|
391
|
+
@display_email = attributes[:display_email]
|
|
392
|
+
@display_username = attributes[:display_username]
|
|
393
|
+
@humanity_score = attributes[:humanity_score]
|
|
394
|
+
@authenticity_score = attributes[:authenticity_score]
|
|
395
|
+
@uniqueness_score = attributes[:uniqueness_score]
|
|
396
|
+
@behavior_score = attributes[:behavior_score]
|
|
397
|
+
@created_at = attributes[:created_at]
|
|
398
|
+
@updated_at = attributes[:updated_at]
|
|
399
|
+
@last_tracked_at = attributes[:last_tracked_at]
|
|
400
|
+
@last_scored_at = attributes[:last_scored_at]
|
|
401
|
+
@disregarded = attributes.fetch(:disregarded, false)
|
|
402
|
+
@badges = attributes.fetch(:badges, [])
|
|
403
|
+
@data = attributes.fetch(:data, {})
|
|
404
|
+
@raw = attributes.fetch(:raw, {})
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Whether the identity is excluded from fraud analysis.
|
|
408
|
+
#
|
|
409
|
+
# @return [Boolean]
|
|
410
|
+
def disregarded?
|
|
411
|
+
disregarded
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# The identity's scores, as a {Scores} for parity with {Identities#scores}.
|
|
415
|
+
#
|
|
416
|
+
# @return [Scores]
|
|
417
|
+
def scores
|
|
418
|
+
pairs = [
|
|
419
|
+
[Category::HUMANITY, humanity_score],
|
|
420
|
+
[Category::AUTHENTICITY, authenticity_score],
|
|
421
|
+
[Category::UNIQUENESS, uniqueness_score],
|
|
422
|
+
[Category::BEHAVIOR, behavior_score]
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
Scores.new(
|
|
426
|
+
pairs.reject { |(_, value)| value.nil? }
|
|
427
|
+
.map { |(category, value)| Score.new(category: category, value: value) }
|
|
428
|
+
)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# @api private
|
|
432
|
+
def self.from_api(payload)
|
|
433
|
+
payload = Parsing.hash_or_empty(payload)
|
|
434
|
+
|
|
435
|
+
new(
|
|
436
|
+
id: payload["id"],
|
|
437
|
+
display_name: payload["displayName"],
|
|
438
|
+
display_email: payload["displayEmail"],
|
|
439
|
+
display_username: payload["displayUsername"],
|
|
440
|
+
humanity_score: payload["humanityScore"],
|
|
441
|
+
authenticity_score: payload["authenticityScore"],
|
|
442
|
+
uniqueness_score: payload["uniquenessScore"],
|
|
443
|
+
behavior_score: payload["behaviorScore"],
|
|
444
|
+
created_at: Parsing.time(payload["createdAt"]),
|
|
445
|
+
updated_at: Parsing.time(payload["updatedAt"]),
|
|
446
|
+
last_tracked_at: Parsing.time(payload["lastTrackedAt"]),
|
|
447
|
+
last_scored_at: Parsing.time(payload["lastScoredAt"]),
|
|
448
|
+
disregarded: payload["disregarded"] == true,
|
|
449
|
+
badges: Parsing.array_or_empty(payload["badges"]).map { |badge| Badge.from_api(badge) },
|
|
450
|
+
data: Parsing.hash_or_empty(payload["data"]),
|
|
451
|
+
raw: payload
|
|
452
|
+
)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# One analysis cycle: the scores an identity was given, and why.
|
|
457
|
+
#
|
|
458
|
+
# @!attribute [r] id
|
|
459
|
+
# @return [Integer, nil] the cycle's identifier
|
|
460
|
+
# @!attribute [r] identity_id
|
|
461
|
+
# @return [String, nil] the identity that was analyzed
|
|
462
|
+
# @!attribute [r] scores
|
|
463
|
+
# @return [Scores] the category scores, each carrying its observations
|
|
464
|
+
# @!attribute [r] event_count
|
|
465
|
+
# @return [Integer, nil] how many events the cycle considered
|
|
466
|
+
# @!attribute [r] device_count
|
|
467
|
+
# @return [Integer, nil] how many devices the cycle considered
|
|
468
|
+
# @!attribute [r] duration_millis
|
|
469
|
+
# @return [Integer, nil] how long the cycle took
|
|
470
|
+
# @!attribute [r] started_at
|
|
471
|
+
# @return [Time, nil]
|
|
472
|
+
# @!attribute [r] finished_at
|
|
473
|
+
# @return [Time, nil]
|
|
474
|
+
# @!attribute [r] raw
|
|
475
|
+
# @return [Hash] the cycle as received
|
|
476
|
+
class Analysis
|
|
477
|
+
attr_reader :id, :identity_id, :scores, :event_count, :device_count, :duration_millis,
|
|
478
|
+
:started_at, :finished_at, :raw
|
|
479
|
+
|
|
480
|
+
# @api private
|
|
481
|
+
def initialize(attributes)
|
|
482
|
+
@id = attributes[:id]
|
|
483
|
+
@identity_id = attributes[:identity_id]
|
|
484
|
+
@scores = attributes.fetch(:scores) { Scores.new }
|
|
485
|
+
@event_count = attributes[:event_count]
|
|
486
|
+
@device_count = attributes[:device_count]
|
|
487
|
+
@duration_millis = attributes[:duration_millis]
|
|
488
|
+
@started_at = attributes[:started_at]
|
|
489
|
+
@finished_at = attributes[:finished_at]
|
|
490
|
+
@raw = attributes.fetch(:raw, {})
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Every observation from the cycle, across all four categories.
|
|
494
|
+
#
|
|
495
|
+
# This is the flat list to log or render when you need to show why an identity scored the way
|
|
496
|
+
# it did; +scores+ keeps them grouped by category.
|
|
497
|
+
#
|
|
498
|
+
# @return [Array<Observation>]
|
|
499
|
+
def observations
|
|
500
|
+
scores.flat_map(&:observations)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# @api private
|
|
504
|
+
def self.from_api(payload)
|
|
505
|
+
payload = Parsing.hash_or_empty(payload)
|
|
506
|
+
|
|
507
|
+
new(
|
|
508
|
+
id: payload["id"],
|
|
509
|
+
identity_id: payload["identityId"],
|
|
510
|
+
scores: Scores.from_api(payload["scores"]),
|
|
511
|
+
event_count: payload["eventCount"],
|
|
512
|
+
device_count: payload["deviceCount"],
|
|
513
|
+
duration_millis: payload["durationMillis"],
|
|
514
|
+
started_at: Parsing.time(payload["startedAt"]),
|
|
515
|
+
finished_at: Parsing.time(payload["finishedAt"]),
|
|
516
|
+
raw: payload
|
|
517
|
+
)
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module Dregs
|
|
10
|
+
# Verifying webhooks Dregs sends you.
|
|
11
|
+
#
|
|
12
|
+
# Dregs signs every webhook with the channel's signing secret: +X-Dregs-Signature+ is the
|
|
13
|
+
# hex-encoded HMAC-SHA256 of the raw request body. Verify it before you act on the payload, and
|
|
14
|
+
# verify it against the bytes you received rather than a re-serialized hash, because
|
|
15
|
+
# re-serializing changes key order and whitespace and will not match.
|
|
16
|
+
#
|
|
17
|
+
# post "/webhooks/dregs" do
|
|
18
|
+
# event = Dregs::Webhooks.verify(
|
|
19
|
+
# payload: request.body.read,
|
|
20
|
+
# signature: request.env["HTTP_X_DREGS_SIGNATURE"],
|
|
21
|
+
# secret: ENV.fetch("DREGS_WEBHOOK_SECRET")
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# handle(event)
|
|
25
|
+
# rescue Dregs::WebhookVerificationError
|
|
26
|
+
# halt 400
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# In Rails that raw body is +request.raw_post+; in Rack it is +request.body.read+, and you may
|
|
30
|
+
# need to +rewind+ the body if something downstream reads it again.
|
|
31
|
+
#
|
|
32
|
+
# The signing secret is shown once, when you create the webhook channel. It is not your API
|
|
33
|
+
# secret key: one authenticates you to Dregs, the other proves a payload came from Dregs.
|
|
34
|
+
module Webhooks
|
|
35
|
+
# The header carrying the signature.
|
|
36
|
+
SIGNATURE_HEADER = "X-Dregs-Signature"
|
|
37
|
+
|
|
38
|
+
# The header carrying the delivery's timestamp. The signed body carries its own copy, which is
|
|
39
|
+
# the one {verify} checks, because a header can be edited without breaking the signature.
|
|
40
|
+
TIMESTAMP_HEADER = "X-Dregs-Timestamp"
|
|
41
|
+
|
|
42
|
+
# The header naming the event type.
|
|
43
|
+
EVENT_HEADER = "X-Dregs-Event"
|
|
44
|
+
|
|
45
|
+
# How far out of date a webhook's timestamp may be, in seconds, before {verify} rejects it.
|
|
46
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Returns the hex-encoded HMAC-SHA256 of +payload+ under +secret+.
|
|
51
|
+
#
|
|
52
|
+
# @param payload [String] the raw request body
|
|
53
|
+
# @param secret [String] the channel's signing secret
|
|
54
|
+
# @return [String]
|
|
55
|
+
def compute_signature(payload:, secret:)
|
|
56
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, payload.to_s)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns whether +signature+ matches +payload+.
|
|
60
|
+
#
|
|
61
|
+
# The comparison is constant-time. Prefer {verify}, which also rejects replays and hands back
|
|
62
|
+
# the parsed event; reach for this one only when you need the boolean.
|
|
63
|
+
#
|
|
64
|
+
# @param payload [String] the raw request body, exactly as received
|
|
65
|
+
# @param signature [String] the +X-Dregs-Signature+ header
|
|
66
|
+
# @param secret [String] the channel's signing secret
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def verify_signature(payload:, signature:, secret:)
|
|
69
|
+
return false if signature.nil? || signature.to_s.strip.empty?
|
|
70
|
+
return false if secret.nil? || secret.to_s.empty?
|
|
71
|
+
|
|
72
|
+
OpenSSL.secure_compare(compute_signature(payload: payload, secret: secret), signature.to_s.strip)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Verifies a webhook and returns its parsed body.
|
|
76
|
+
#
|
|
77
|
+
# @param payload [String] the raw request body, exactly as received. Not a parsed hash.
|
|
78
|
+
# @param signature [String] the +X-Dregs-Signature+ header
|
|
79
|
+
# @param secret [String] the channel's signing secret
|
|
80
|
+
# @param tolerance [Numeric, nil] how many seconds out of date the payload's own +timestamp+
|
|
81
|
+
# may be before it is treated as a replay. Pass nil to skip the check, which you should only
|
|
82
|
+
# do if you are deduplicating on the event id yourself. The timestamp is inside the signed
|
|
83
|
+
# body, so an attacker cannot alter it without breaking the signature.
|
|
84
|
+
# @param now [Time, nil] the current time, for tests
|
|
85
|
+
# @return [Hash] the parsed webhook body: +"event"+, +"timestamp"+, and the payload for that
|
|
86
|
+
# event, with string keys exactly as Dregs sent them
|
|
87
|
+
# @raise [WebhookVerificationError] the signature did not match, the body was not a JSON
|
|
88
|
+
# object, or the payload is older than +tolerance+
|
|
89
|
+
def verify(payload:, signature:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS, now: nil)
|
|
90
|
+
unless verify_signature(payload: payload, signature: signature, secret: secret)
|
|
91
|
+
raise WebhookVerificationError,
|
|
92
|
+
"The webhook signature did not match. Check that you are verifying the raw request " \
|
|
93
|
+
"body rather than a re-serialized copy, and that the signing secret belongs to the " \
|
|
94
|
+
"channel that sent this delivery."
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
event = parse(payload)
|
|
98
|
+
|
|
99
|
+
check_freshness(event, tolerance: tolerance, now: now) unless tolerance.nil?
|
|
100
|
+
|
|
101
|
+
event
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @api private
|
|
105
|
+
def parse(payload)
|
|
106
|
+
event =
|
|
107
|
+
begin
|
|
108
|
+
JSON.parse(payload.to_s)
|
|
109
|
+
rescue JSON::ParserError => e
|
|
110
|
+
raise WebhookVerificationError, "The webhook body was not valid JSON: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
raise WebhookVerificationError, "The webhook body was not a JSON object." unless event.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
event
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# @api private
|
|
119
|
+
def check_freshness(event, tolerance:, now:)
|
|
120
|
+
raw = event["timestamp"]
|
|
121
|
+
|
|
122
|
+
if !raw.is_a?(String) || raw.empty?
|
|
123
|
+
raise WebhookVerificationError,
|
|
124
|
+
"The webhook carried no timestamp, so it cannot be checked for replay. Pass " \
|
|
125
|
+
"tolerance: nil if you are deduplicating deliveries some other way."
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
sent =
|
|
129
|
+
begin
|
|
130
|
+
Time.iso8601(raw)
|
|
131
|
+
rescue ArgumentError
|
|
132
|
+
raise WebhookVerificationError, "The webhook timestamp was unreadable: #{raw.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
age = ((now || Time.now).getutc - sent.getutc).abs
|
|
136
|
+
|
|
137
|
+
return if age <= tolerance
|
|
138
|
+
|
|
139
|
+
raise WebhookVerificationError,
|
|
140
|
+
"The webhook timestamp is #{age.round}s away from now, beyond the #{tolerance.round}s " \
|
|
141
|
+
"tolerance. Treating it as a replay."
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/dregs.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "dregs/version"
|
|
4
|
+
require_relative "dregs/errors"
|
|
5
|
+
require_relative "dregs/models"
|
|
6
|
+
require_relative "dregs/identities"
|
|
7
|
+
require_relative "dregs/client"
|
|
8
|
+
require_relative "dregs/webhooks"
|
|
9
|
+
|
|
10
|
+
# Dregs: fraud and abuse scoring for the users of your application.
|
|
11
|
+
#
|
|
12
|
+
# Send events from your backend, read back the scores and the observations behind them.
|
|
13
|
+
#
|
|
14
|
+
# require "dregs"
|
|
15
|
+
#
|
|
16
|
+
# client = Dregs::Client.new
|
|
17
|
+
#
|
|
18
|
+
# client.track(
|
|
19
|
+
# "user.signup",
|
|
20
|
+
# identity: "user_12345",
|
|
21
|
+
# data: { plan: "pro" },
|
|
22
|
+
# identity_data: { email: "ada@example.com" }
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
# scores = client.identities.scores("user_12345")
|
|
26
|
+
#
|
|
27
|
+
# hold_for_review("user_12345") if scores.authenticity && scores.authenticity < 40
|
|
28
|
+
#
|
|
29
|
+
# Scoring is asynchronous, so scores appear moments after the events that move them rather than in
|
|
30
|
+
# the same breath. See https://dregs.com/manual/api/ for the API this wraps.
|
|
31
|
+
module Dregs
|
|
32
|
+
# Builds a {Client}. A convenience for +Dregs::Client.new+, which takes the same arguments.
|
|
33
|
+
#
|
|
34
|
+
# @return [Client]
|
|
35
|
+
def self.new(**options)
|
|
36
|
+
Client.new(**options)
|
|
37
|
+
end
|
|
38
|
+
end
|