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.
@@ -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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dregs
4
+ # The version of this gem, reported in the User-Agent of every request it makes.
5
+ VERSION = "0.1.0"
6
+ 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