reputable 0.1.25 → 0.1.26
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/Gemfile.lock +21 -19
- data/README.md +129 -14
- data/lib/reputable/configuration.rb +26 -0
- data/lib/reputable/measurement.rb +238 -0
- data/lib/reputable/middleware.rb +31 -0
- data/lib/reputable/rails.rb +35 -0
- data/lib/reputable/signals.rb +84 -0
- data/lib/reputable/tracker.rb +174 -1
- data/lib/reputable/verify.rb +146 -0
- data/lib/reputable/version.rb +1 -1
- data/lib/reputable.rb +69 -0
- data/reputable.gemspec +1 -0
- metadata +25 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 85f3feb44268a1779722dac5bd55976e534db3ea5d4ff86b541b96fb953c4c5d
|
|
4
|
+
data.tar.gz: a86937950a3032633c0bc81b4fce24ea833dc68c38f5eb7aecf071cecdbbc17f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 851d6c143940682015369cb1592fad14742e271550dcc36bb2717f404c0c3c13883ed7abc7de6ffaee8b3baa34e15850353aa55085e5a5f79955a6f16ceb687a
|
|
7
|
+
data.tar.gz: b9cc8b8dacbbbc9731a806d15380e3455ae21ab78e0b0a09990a6db721cc26bbae51383afffb9a0de07fc7953a4098a4d06340d7bbc23ea2a1e84c7e8197c56f
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
reputable (0.1.
|
|
4
|
+
reputable (0.1.26)
|
|
5
|
+
base64 (>= 0.2, < 0.4)
|
|
5
6
|
connection_pool (~> 2.2)
|
|
6
7
|
redis (>= 4.0, < 6.0)
|
|
7
8
|
|
|
@@ -9,25 +10,26 @@ GEM
|
|
|
9
10
|
remote: https://rubygems.org/
|
|
10
11
|
specs:
|
|
11
12
|
ast (2.4.3)
|
|
13
|
+
base64 (0.3.0)
|
|
12
14
|
connection_pool (2.5.5)
|
|
13
15
|
diff-lcs (1.6.2)
|
|
14
|
-
json (2.
|
|
16
|
+
json (2.19.8)
|
|
15
17
|
language_server-protocol (3.17.0.5)
|
|
16
18
|
lint_roller (1.1.0)
|
|
17
|
-
parallel (1.
|
|
18
|
-
parser (3.3.
|
|
19
|
+
parallel (1.28.0)
|
|
20
|
+
parser (3.3.11.1)
|
|
19
21
|
ast (~> 2.4.1)
|
|
20
22
|
racc
|
|
21
|
-
prism (1.
|
|
23
|
+
prism (1.9.0)
|
|
22
24
|
racc (1.8.1)
|
|
23
|
-
rack (2.2.
|
|
25
|
+
rack (2.2.23)
|
|
24
26
|
rainbow (3.1.1)
|
|
25
|
-
rake (13.
|
|
27
|
+
rake (13.4.2)
|
|
26
28
|
redis (5.4.1)
|
|
27
29
|
redis-client (>= 0.22.0)
|
|
28
|
-
redis-client (0.
|
|
30
|
+
redis-client (0.29.0)
|
|
29
31
|
connection_pool
|
|
30
|
-
regexp_parser (2.
|
|
32
|
+
regexp_parser (2.12.0)
|
|
31
33
|
rspec (3.13.2)
|
|
32
34
|
rspec-core (~> 3.13.0)
|
|
33
35
|
rspec-expectations (~> 3.13.0)
|
|
@@ -37,24 +39,24 @@ GEM
|
|
|
37
39
|
rspec-expectations (3.13.5)
|
|
38
40
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
39
41
|
rspec-support (~> 3.13.0)
|
|
40
|
-
rspec-mocks (3.13.
|
|
42
|
+
rspec-mocks (3.13.8)
|
|
41
43
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
42
44
|
rspec-support (~> 3.13.0)
|
|
43
|
-
rspec-support (3.13.
|
|
44
|
-
rubocop (1.
|
|
45
|
+
rspec-support (3.13.7)
|
|
46
|
+
rubocop (1.87.0)
|
|
45
47
|
json (~> 2.3)
|
|
46
48
|
language_server-protocol (~> 3.17.0.2)
|
|
47
49
|
lint_roller (~> 1.1.0)
|
|
48
|
-
parallel (
|
|
50
|
+
parallel (>= 1.10)
|
|
49
51
|
parser (>= 3.3.0.2)
|
|
50
52
|
rainbow (>= 2.2.2, < 4.0)
|
|
51
53
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
52
|
-
rubocop-ast (>= 1.
|
|
54
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
53
55
|
ruby-progressbar (~> 1.7)
|
|
54
56
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
55
|
-
rubocop-ast (1.
|
|
57
|
+
rubocop-ast (1.49.1)
|
|
56
58
|
parser (>= 3.3.7.2)
|
|
57
|
-
prism (~> 1.
|
|
59
|
+
prism (~> 1.7)
|
|
58
60
|
ruby-progressbar (1.13.0)
|
|
59
61
|
unicode-display_width (3.2.0)
|
|
60
62
|
unicode-emoji (~> 4.1)
|
|
@@ -69,8 +71,8 @@ DEPENDENCIES
|
|
|
69
71
|
rack (~> 2.0)
|
|
70
72
|
rake (~> 13.0)
|
|
71
73
|
reputable!
|
|
72
|
-
rspec (~> 3.0)
|
|
73
|
-
rubocop (~> 1.21)
|
|
74
|
+
rspec (~> 3.12, ~> 3.0)
|
|
75
|
+
rubocop (~> 1.21, ~> 1.0)
|
|
74
76
|
|
|
75
77
|
BUNDLED WITH
|
|
76
|
-
|
|
78
|
+
4.0.14
|
data/README.md
CHANGED
|
@@ -245,6 +245,77 @@ REPUTABLE_SSL_VERIFY=false
|
|
|
245
245
|
|
|
246
246
|
---
|
|
247
247
|
|
|
248
|
+
## Hosted Measurement
|
|
249
|
+
|
|
250
|
+
Hosted measurement is the shared `cloud.reputable.click` path for per-request
|
|
251
|
+
proxy, bot, timing, ASN, geo, and anomaly checks. It does not require Redis and
|
|
252
|
+
does not replace the dedicated Reputable middleware path above.
|
|
253
|
+
|
|
254
|
+
Configure the customer credentials issued from the Reputable `/measure` page:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
Reputable.configure do |config|
|
|
258
|
+
config.measurement_base_url = ENV["REPUTABLE_MEASURE_BASE_URL"] # e.g. https://cloud.reputable.click
|
|
259
|
+
config.measurement_customer_id = ENV["REPUTABLE_MEASURE_CUSTOMER_ID"]
|
|
260
|
+
config.measurement_capability = ENV["REPUTABLE_MEASURE_CAPABILITY"]
|
|
261
|
+
config.measurement_private_jwk = JSON.parse(ENV.fetch("REPUTABLE_MEASURE_PRIVATE_JWK"))
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Mint a short-lived token for the browser to send to the hosted collect endpoint:
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
token = Reputable.measurement_token(
|
|
269
|
+
mode: "collect",
|
|
270
|
+
session_ref: session.id,
|
|
271
|
+
request_id: request.request_id,
|
|
272
|
+
origin: request.base_url,
|
|
273
|
+
request_context: {
|
|
274
|
+
scheme: request.scheme,
|
|
275
|
+
host: request.host,
|
|
276
|
+
method: request.request_method,
|
|
277
|
+
path: request.path,
|
|
278
|
+
query: request.query_string,
|
|
279
|
+
page_type: "product",
|
|
280
|
+
action_type: "view"
|
|
281
|
+
}
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
collect_url = Reputable.measurement_collect_url(token)
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
After the browser returns the signed `resultToken`, verify it server-side before
|
|
288
|
+
allowing an expensive or abuse-sensitive action:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
result = Reputable.verify_measurement_result(result_token)
|
|
292
|
+
|
|
293
|
+
if result && result["outcome"] != "deny"
|
|
294
|
+
# Continue with the protected action.
|
|
295
|
+
else
|
|
296
|
+
# Review, challenge, or block according to your app policy.
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
For offline verification without fetching JWKS from the measurement fleet, set:
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
config.measurement_result_jwks = JSON.parse(ENV.fetch("REPUTABLE_MEASURE_RESULT_JWKS"))
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Relevant environment variables:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
REPUTABLE_MEASURE_BASE_URL=https://cloud.reputable.click
|
|
310
|
+
REPUTABLE_MEASURE_CUSTOMER_ID=cus_...
|
|
311
|
+
REPUTABLE_MEASURE_CAPABILITY=eyJ...
|
|
312
|
+
REPUTABLE_MEASURE_PRIVATE_JWK='{"kty":"EC","crv":"P-256",...}'
|
|
313
|
+
REPUTABLE_MEASURE_RESULT_JWKS='{"keys":[...]}'
|
|
314
|
+
REPUTABLE_MEASURE_TOKEN_TTL=120
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
248
319
|
## Middleware Configuration
|
|
249
320
|
|
|
250
321
|
### Basic Usage
|
|
@@ -471,6 +542,43 @@ Reputable.track_request_async(
|
|
|
471
542
|
)
|
|
472
543
|
```
|
|
473
544
|
|
|
545
|
+
### Trusted Security Events
|
|
546
|
+
|
|
547
|
+
For the full cross-service contract and Go API behavior, see [docs/SECURITY_EVENTS.md](../../docs/SECURITY_EVENTS.md).
|
|
548
|
+
|
|
549
|
+
```ruby
|
|
550
|
+
Reputable.track_security_event(
|
|
551
|
+
event_type: "login_failed",
|
|
552
|
+
ip: request.ip,
|
|
553
|
+
account_id: current_account.id,
|
|
554
|
+
user_id: current_user.id,
|
|
555
|
+
session_id: session.id.to_s,
|
|
556
|
+
request_id: request.request_id,
|
|
557
|
+
reason_code: "invalid_password",
|
|
558
|
+
context: {
|
|
559
|
+
country: "US",
|
|
560
|
+
proxy_class: "none",
|
|
561
|
+
ja4: request.headers["X-JA4"],
|
|
562
|
+
ua_family: "chrome"
|
|
563
|
+
},
|
|
564
|
+
resource: {
|
|
565
|
+
type: "account",
|
|
566
|
+
id: current_account.id,
|
|
567
|
+
action: "login"
|
|
568
|
+
},
|
|
569
|
+
metadata: { auth_method: "password" }
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
# Async (recommended)
|
|
573
|
+
Reputable.track_security_event_async(
|
|
574
|
+
event_type: "permission_denied",
|
|
575
|
+
ip: request.ip,
|
|
576
|
+
account_id: current_account.id,
|
|
577
|
+
user_id: current_user.id,
|
|
578
|
+
reason_code: "missing_scope"
|
|
579
|
+
)
|
|
580
|
+
```
|
|
581
|
+
|
|
474
582
|
### Reputation Management
|
|
475
583
|
|
|
476
584
|
```ruby
|
|
@@ -645,14 +753,14 @@ If you need to verify manually (e.g., custom controller logic):
|
|
|
645
753
|
|
|
646
754
|
```ruby
|
|
647
755
|
# In your controller action (the return_url target)
|
|
648
|
-
if params[:reputable_signature]
|
|
756
|
+
if params[:reputable_s] || params[:reputable_signature]
|
|
649
757
|
if Reputable.verify_redirect_return(params)
|
|
650
758
|
# Signature is VALID. Meaning Reputable actually sent this user back.
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
status = params[:reputable_status]
|
|
654
|
-
outcome = params[:reputable_outcome]
|
|
655
|
-
country = params[:reputable_country]
|
|
759
|
+
|
|
760
|
+
decoded = Reputable.decode_reputable_response(params)
|
|
761
|
+
status = decoded&.dig("status") || params[:reputable_status]
|
|
762
|
+
outcome = decoded&.dig("outcome") || params[:reputable_outcome]
|
|
763
|
+
country = decoded&.dig("country") || params[:reputable_country]
|
|
656
764
|
|
|
657
765
|
if status == 'pass'
|
|
658
766
|
# Grant access
|
|
@@ -665,14 +773,21 @@ end
|
|
|
665
773
|
```
|
|
666
774
|
|
|
667
775
|
**Return Parameters:**
|
|
668
|
-
The
|
|
669
|
-
|
|
670
|
-
- `
|
|
671
|
-
- `
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
- `
|
|
776
|
+
The current Go API returns compact signed parameters:
|
|
777
|
+
|
|
778
|
+
- `reputable_r`: Base64url-encoded response payload
|
|
779
|
+
- `reputable_s`: HMAC-SHA256 signature of the decoded payload
|
|
780
|
+
|
|
781
|
+
Decode `reputable_r` with `Reputable.decode_reputable_response(params)` to read:
|
|
782
|
+
|
|
783
|
+
- `status`: `pass` or `fail`
|
|
784
|
+
- `session_id`: The session ID you provided
|
|
785
|
+
- `outcome`: The specific reputation outcome (e.g., `trusted_verified`)
|
|
786
|
+
- `ignore_analytics`: `true`/`false` flag
|
|
787
|
+
- `country`: ISO country code
|
|
788
|
+
- `challenge_passed`: `true` when an interactive challenge was completed
|
|
789
|
+
|
|
790
|
+
The Ruby client still accepts the legacy expanded parameters (`reputable_status`, `reputable_session_id`, `reputable_outcome`, `reputable_ignore_analytics`, `reputable_country`, `reputable_challenge_passed`, `reputable_signature`) for backward compatibility.
|
|
676
791
|
|
|
677
792
|
---
|
|
678
793
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "openssl"
|
|
4
4
|
require "ipaddr"
|
|
5
|
+
require "json"
|
|
5
6
|
|
|
6
7
|
module Reputable
|
|
7
8
|
# Configuration class for Reputable client
|
|
@@ -15,6 +16,11 @@ module Reputable
|
|
|
15
16
|
:connect_timeout, :read_timeout, :write_timeout,
|
|
16
17
|
:ssl_params, :trusted_proxies, :ip_header_priority,
|
|
17
18
|
:on_error, :trusted_keys, :base_url,
|
|
19
|
+
:measurement_base_url, :measurement_customer_id,
|
|
20
|
+
:measurement_result_jwks, :measurement_audience,
|
|
21
|
+
:signals_secret_key, :signals_publishable_key,
|
|
22
|
+
:measurement_capability, :measurement_private_jwk,
|
|
23
|
+
:measurement_token_ttl,
|
|
18
24
|
:site_name, :support_email, :support_url,
|
|
19
25
|
:verbose
|
|
20
26
|
|
|
@@ -80,6 +86,17 @@ module Reputable
|
|
|
80
86
|
@trusted_keys = [ENV["REPUTABLE_SECRET_KEY"]]
|
|
81
87
|
end
|
|
82
88
|
@base_url = ENV.fetch("REPUTABLE_BASE_URL", "https://api.reputable.click")
|
|
89
|
+
@measurement_base_url = ENV["REPUTABLE_SIGNALS_BASE_URL"] || ENV["REPUTABLE_MEASURE_BASE_URL"]
|
|
90
|
+
@measurement_customer_id = ENV["REPUTABLE_SIGNALS_CUSTOMER_ID"] || ENV["REPUTABLE_MEASURE_CUSTOMER_ID"]
|
|
91
|
+
@measurement_result_jwks = parse_json_env("REPUTABLE_SIGNALS_JWKS") || parse_json_env("REPUTABLE_MEASURE_RESULT_JWKS")
|
|
92
|
+
@measurement_audience = ENV.fetch("REPUTABLE_MEASURE_AUDIENCE", "reputable-measure")
|
|
93
|
+
@signals_secret_key = ENV["REPUTABLE_SIGNALS_SECRET_KEY"]
|
|
94
|
+
@signals_publishable_key = ENV["REPUTABLE_SIGNALS_PUBLISHABLE_KEY"]
|
|
95
|
+
# Legacy capability-era settings; kept as readable attributes so old
|
|
96
|
+
# initializers do not crash, but no longer used.
|
|
97
|
+
@measurement_capability = ENV["REPUTABLE_MEASURE_CAPABILITY"]
|
|
98
|
+
@measurement_private_jwk = parse_json_env("REPUTABLE_MEASURE_PRIVATE_JWK")
|
|
99
|
+
@measurement_token_ttl = Integer(ENV.fetch("REPUTABLE_MEASURE_TOKEN_TTL", "120"))
|
|
83
100
|
@site_name = ENV["REPUTABLE_SITE_NAME"]
|
|
84
101
|
@support_email = ENV["REPUTABLE_SUPPORT_EMAIL"]
|
|
85
102
|
@support_url = ENV["REPUTABLE_SUPPORT_URL"]
|
|
@@ -124,6 +141,15 @@ module Reputable
|
|
|
124
141
|
@default_ttls[status_sym] || DEFAULT_TTLS[:untrusted_challenge]
|
|
125
142
|
end
|
|
126
143
|
|
|
144
|
+
def parse_json_env(name)
|
|
145
|
+
raw = ENV[name]
|
|
146
|
+
return nil if raw.nil? || raw.empty?
|
|
147
|
+
|
|
148
|
+
JSON.parse(raw)
|
|
149
|
+
rescue JSON::ParserError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
127
153
|
# Check if Redis URL uses TLS (rediss:// scheme)
|
|
128
154
|
def tls?
|
|
129
155
|
redis_url&.start_with?("rediss://")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "securerandom"
|
|
8
|
+
require "time"
|
|
9
|
+
require "uri"
|
|
10
|
+
|
|
11
|
+
module Reputable
|
|
12
|
+
# Helpers for the hosted Bot Signals API.
|
|
13
|
+
#
|
|
14
|
+
# This path is independent from the Redis-backed reputation middleware. It
|
|
15
|
+
# verifies signed tickets offline against the published JWKS. The old
|
|
16
|
+
# customer-minted measurement-token flow is gone: servers call the check API
|
|
17
|
+
# with a plain secret key (see Reputable::Signals) and browsers use a
|
|
18
|
+
# publishable key.
|
|
19
|
+
module Measurement
|
|
20
|
+
DEFAULT_AUDIENCE = "reputable-measure"
|
|
21
|
+
|
|
22
|
+
MINTING_REMOVED = "Reputable no longer uses customer-minted measurement tokens. " \
|
|
23
|
+
"Use your secret key with Reputable::Signals.check (server) or your " \
|
|
24
|
+
"publishable key in the browser snippet/redirect URL instead."
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# @deprecated The two-key model replaced customer-minted JWTs.
|
|
29
|
+
def measurement_token(**)
|
|
30
|
+
raise NotImplementedError, MINTING_REMOVED
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @deprecated See {Reputable::Signals.redirect_url}.
|
|
34
|
+
def collect_url(_token = nil)
|
|
35
|
+
raise NotImplementedError, MINTING_REMOVED
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @deprecated See {Reputable::Signals.redirect_url}.
|
|
39
|
+
def redirect_url(_token = nil)
|
|
40
|
+
raise NotImplementedError, MINTING_REMOVED
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def verify_result_token(token, jwks: nil, expected_customer_id: nil, audience: nil)
|
|
44
|
+
return nil if blank?(token)
|
|
45
|
+
|
|
46
|
+
jwks ||= Reputable.configuration.measurement_result_jwks || fetch_measurement_jwks
|
|
47
|
+
keys = Array(jwks.is_a?(Hash) ? jwks["keys"] || jwks[:keys] : jwks)
|
|
48
|
+
return nil if keys.empty?
|
|
49
|
+
|
|
50
|
+
header, payload, signed_data, signature = decode_jwt(token)
|
|
51
|
+
return nil unless header["alg"] == "ES256"
|
|
52
|
+
|
|
53
|
+
key = select_key(keys, header["kid"])
|
|
54
|
+
return nil unless key && verify_es256_signature(key, signed_data, signature)
|
|
55
|
+
|
|
56
|
+
now = Time.now.to_i
|
|
57
|
+
return nil if payload["exp"] && now >= payload["exp"].to_i
|
|
58
|
+
return nil if payload["nbf"] && now < payload["nbf"].to_i
|
|
59
|
+
|
|
60
|
+
expected_customer_id ||= Reputable.configuration.measurement_customer_id
|
|
61
|
+
audience ||= expected_customer_id
|
|
62
|
+
return nil if present?(expected_customer_id) && payload["customerId"] != expected_customer_id
|
|
63
|
+
return nil if present?(audience) && !audience_matches?(payload["aud"], audience)
|
|
64
|
+
|
|
65
|
+
payload
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
Reputable.logger&.warn "Reputable: Failed to verify measurement result: #{e.message}"
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def verify_result(token, **kwargs)
|
|
72
|
+
claims = verify_result_token(token, **kwargs)
|
|
73
|
+
claims && (claims["report"] || claims["result"])
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def measurement_base_url
|
|
77
|
+
(Reputable.configuration.measurement_base_url || Reputable.configuration.base_url).to_s.chomp("/")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def fetch_measurement_jwks
|
|
81
|
+
fetch_jwks_path("/v1/signals/jwks") || fetch_jwks_path("/v1/verify/jwks")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def fetch_jwks_path(path)
|
|
85
|
+
uri = URI("#{measurement_base_url}#{path}")
|
|
86
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
87
|
+
open_timeout: Reputable.configuration.connect_timeout,
|
|
88
|
+
read_timeout: Reputable.configuration.read_timeout) do |http|
|
|
89
|
+
http.get(uri.request_uri)
|
|
90
|
+
end
|
|
91
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
92
|
+
|
|
93
|
+
JSON.parse(response.body)
|
|
94
|
+
rescue StandardError => e
|
|
95
|
+
Reputable.logger&.warn "Reputable: Failed to fetch JWKS from #{path}: #{e.message}"
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_request_context(value)
|
|
100
|
+
raw = stringify_keys(value || {})
|
|
101
|
+
{
|
|
102
|
+
scheme: raw["scheme"],
|
|
103
|
+
host: raw["host"],
|
|
104
|
+
method: raw["method"]&.upcase,
|
|
105
|
+
path: raw["path"],
|
|
106
|
+
query: raw["query"],
|
|
107
|
+
url: raw["url"],
|
|
108
|
+
referrer: raw["referrer"] || raw["referer"],
|
|
109
|
+
pageTitle: raw["pageTitle"] || raw["page_title"],
|
|
110
|
+
pageType: raw["pageType"] || raw["page_type"],
|
|
111
|
+
actionType: raw["actionType"] || raw["action_type"]
|
|
112
|
+
}.compact
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def sign_es256_jwt(payload, private_jwk)
|
|
116
|
+
jwk = stringify_keys(private_jwk)
|
|
117
|
+
header = { alg: "ES256", typ: "JWT" }
|
|
118
|
+
header[:kid] = jwk["kid"] if present?(jwk["kid"])
|
|
119
|
+
|
|
120
|
+
signed_data = "#{base64url_encode(JSON.generate(header))}.#{base64url_encode(JSON.generate(payload))}"
|
|
121
|
+
der_signature = ec_private_key_from_jwk(jwk).dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signed_data))
|
|
122
|
+
"#{signed_data}.#{base64url_encode(der_to_jose_signature(der_signature))}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def decode_jwt(token)
|
|
126
|
+
parts = token.to_s.split(".")
|
|
127
|
+
raise ArgumentError, "invalid JWT" unless parts.length == 3
|
|
128
|
+
|
|
129
|
+
header = JSON.parse(base64url_decode(parts[0]))
|
|
130
|
+
payload = JSON.parse(base64url_decode(parts[1]))
|
|
131
|
+
[header, payload, "#{parts[0]}.#{parts[1]}", base64url_decode(parts[2])]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def verify_es256_signature(jwk, signed_data, jose_signature)
|
|
135
|
+
return false unless jose_signature.bytesize == 64
|
|
136
|
+
|
|
137
|
+
digest = OpenSSL::Digest::SHA256.digest(signed_data)
|
|
138
|
+
ec_public_key_from_jwk(stringify_keys(jwk)).dsa_verify_asn1(digest, jose_to_der_signature(jose_signature))
|
|
139
|
+
rescue StandardError
|
|
140
|
+
false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Keys are built from DER rather than assigned via key.private_key= /
|
|
144
|
+
# key.public_key=, which raise "pkeys are immutable" on OpenSSL 3.
|
|
145
|
+
def ec_private_key_from_jwk(jwk)
|
|
146
|
+
d = base64url_decode(jwk.fetch("d"))
|
|
147
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
|
148
|
+
OpenSSL::ASN1::Integer(1),
|
|
149
|
+
OpenSSL::ASN1::OctetString(d.rjust(32, "\x00")),
|
|
150
|
+
OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::ObjectId("prime256v1")], 0, :CONTEXT_SPECIFIC),
|
|
151
|
+
OpenSSL::ASN1::ASN1Data.new(
|
|
152
|
+
[OpenSSL::ASN1::BitString(public_point_from_jwk(jwk).to_octet_string(:uncompressed))],
|
|
153
|
+
1, :CONTEXT_SPECIFIC
|
|
154
|
+
)
|
|
155
|
+
])
|
|
156
|
+
OpenSSL::PKey::EC.new(asn1.to_der)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def ec_public_key_from_jwk(jwk)
|
|
160
|
+
asn1 = OpenSSL::ASN1::Sequence([
|
|
161
|
+
OpenSSL::ASN1::Sequence([
|
|
162
|
+
OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
|
|
163
|
+
OpenSSL::ASN1::ObjectId("prime256v1")
|
|
164
|
+
]),
|
|
165
|
+
OpenSSL::ASN1::BitString(public_point_from_jwk(jwk).to_octet_string(:uncompressed))
|
|
166
|
+
])
|
|
167
|
+
OpenSSL::PKey::EC.new(asn1.to_der)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def public_point_from_jwk(jwk)
|
|
171
|
+
group = OpenSSL::PKey::EC::Group.new("prime256v1")
|
|
172
|
+
x = base64url_decode(jwk.fetch("x"))
|
|
173
|
+
y = base64url_decode(jwk.fetch("y"))
|
|
174
|
+
OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new("\x04#{x}#{y}", 2))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def der_to_jose_signature(der)
|
|
178
|
+
sequence = OpenSSL::ASN1.decode(der)
|
|
179
|
+
r = integer_to_fixed_bytes(sequence.value[0].value.to_i, 32)
|
|
180
|
+
s = integer_to_fixed_bytes(sequence.value[1].value.to_i, 32)
|
|
181
|
+
"#{r}#{s}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def jose_to_der_signature(signature)
|
|
185
|
+
r = OpenSSL::BN.new(signature[0, 32], 2)
|
|
186
|
+
s = OpenSSL::BN.new(signature[32, 32], 2)
|
|
187
|
+
OpenSSL::ASN1::Sequence([
|
|
188
|
+
OpenSSL::ASN1::Integer(r),
|
|
189
|
+
OpenSSL::ASN1::Integer(s)
|
|
190
|
+
]).to_der
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def integer_to_fixed_bytes(value, width)
|
|
194
|
+
hex = value.to_s(16)
|
|
195
|
+
hex = "0#{hex}" if hex.length.odd?
|
|
196
|
+
bytes = [hex].pack("H*")
|
|
197
|
+
bytes.rjust(width, "\x00")[-width, width]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def select_key(keys, kid)
|
|
201
|
+
keys = keys.map { |key| stringify_keys(key) }
|
|
202
|
+
return keys.first if blank?(kid) && keys.length == 1
|
|
203
|
+
|
|
204
|
+
keys.find { |key| key["kid"] == kid }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def audience_matches?(actual, expected)
|
|
208
|
+
Array(actual).map(&:to_s).include?(expected.to_s)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def stringify_keys(value)
|
|
212
|
+
return value unless value.is_a?(Hash)
|
|
213
|
+
|
|
214
|
+
value.each_with_object({}) { |(key, val), out| out[key.to_s] = val }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def base64url_encode(value)
|
|
218
|
+
Base64.urlsafe_encode64(value).delete("=")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def base64url_decode(value)
|
|
222
|
+
padded = value.to_s + "=" * ((4 - value.to_s.length % 4) % 4)
|
|
223
|
+
Base64.urlsafe_decode64(padded)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def escape(value)
|
|
227
|
+
URI.encode_www_form_component(value.to_s)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def present?(value)
|
|
231
|
+
!blank?(value)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def blank?(value)
|
|
235
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
data/lib/reputable/middleware.rb
CHANGED
|
@@ -456,6 +456,9 @@ module Reputable
|
|
|
456
456
|
path: request.path,
|
|
457
457
|
query: request.query_string.empty? ? nil : request.query_string,
|
|
458
458
|
method: request.request_method,
|
|
459
|
+
account_id: tracking_account_id(env),
|
|
460
|
+
session_id: tracking_session_id(env),
|
|
461
|
+
user_id: tracking_user_id(env),
|
|
459
462
|
user_agent: env["HTTP_USER_AGENT"],
|
|
460
463
|
referer: env["HTTP_REFERER"],
|
|
461
464
|
tags: build_tags(env),
|
|
@@ -466,6 +469,34 @@ module Reputable
|
|
|
466
469
|
nil
|
|
467
470
|
end
|
|
468
471
|
|
|
472
|
+
def tracking_session_id(env)
|
|
473
|
+
resolve_session_id(env)
|
|
474
|
+
rescue StandardError
|
|
475
|
+
nil
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def tracking_user_id(env)
|
|
479
|
+
return env["reputable.user_id"] if env["reputable.user_id"]
|
|
480
|
+
|
|
481
|
+
session = env["rack.session"]
|
|
482
|
+
return nil unless session
|
|
483
|
+
|
|
484
|
+
session[:user_id] || session["user_id"]
|
|
485
|
+
rescue StandardError
|
|
486
|
+
nil
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def tracking_account_id(env)
|
|
490
|
+
return env["reputable.account_id"] if env["reputable.account_id"]
|
|
491
|
+
|
|
492
|
+
session = env["rack.session"]
|
|
493
|
+
return nil unless session
|
|
494
|
+
|
|
495
|
+
session[:account_id] || session["account_id"] || session[:tenant_id] || session["tenant_id"]
|
|
496
|
+
rescue StandardError
|
|
497
|
+
nil
|
|
498
|
+
end
|
|
499
|
+
|
|
469
500
|
def extract_ip(env)
|
|
470
501
|
config = Reputable.configuration
|
|
471
502
|
|
data/lib/reputable/rails.rb
CHANGED
|
@@ -25,6 +25,41 @@ module Reputable
|
|
|
25
25
|
)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Track a trusted backend security event for user/account profiling.
|
|
29
|
+
def track_reputable_security_event(
|
|
30
|
+
event_type:,
|
|
31
|
+
outcome: nil,
|
|
32
|
+
reason_code: nil,
|
|
33
|
+
account_id: nil,
|
|
34
|
+
user_id: nil,
|
|
35
|
+
context: {},
|
|
36
|
+
resource: nil,
|
|
37
|
+
metadata: {},
|
|
38
|
+
**options
|
|
39
|
+
)
|
|
40
|
+
base_context = {
|
|
41
|
+
ip: request.remote_ip
|
|
42
|
+
}.merge(context || {})
|
|
43
|
+
|
|
44
|
+
Reputable::Tracker.track_security_event(
|
|
45
|
+
event_type: event_type,
|
|
46
|
+
ip: request.remote_ip,
|
|
47
|
+
path: request.path,
|
|
48
|
+
outcome: outcome,
|
|
49
|
+
reason_code: reason_code,
|
|
50
|
+
account_id: account_id,
|
|
51
|
+
user_id: user_id,
|
|
52
|
+
session_id: options[:session_id] || session.id,
|
|
53
|
+
request_id: options[:request_id] || request.request_id,
|
|
54
|
+
user_agent: request.user_agent,
|
|
55
|
+
referer: request.referer,
|
|
56
|
+
context: base_context,
|
|
57
|
+
resource: resource,
|
|
58
|
+
metadata: metadata,
|
|
59
|
+
**options
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
28
63
|
# Trust the current user's IP (behavioral by default, verified optional)
|
|
29
64
|
def trust_current_ip(reason:, status: :trusted_behavior, ttl: nil, **metadata)
|
|
30
65
|
Reputable::Reputation.trust_ip(
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Reputable
|
|
9
|
+
# Bot Signals server API: ask the hosted fleet for a signal report on an end
|
|
10
|
+
# user's request using your secret key (sk_...). No JWT minting — a plain
|
|
11
|
+
# bearer.
|
|
12
|
+
#
|
|
13
|
+
# @example Check a request before an expensive action
|
|
14
|
+
# check = Reputable::Signals.check(ip: request.remote_ip, user_agent: request.user_agent)
|
|
15
|
+
# report = check && check["report"]
|
|
16
|
+
# flag_traffic if report && report["proxyScore"].to_i >= 70
|
|
17
|
+
module Signals
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# POST /v1/signals/check with the configured secret key. Pass the END
|
|
21
|
+
# USER's ip/user_agent — without them the fleet would score your server's
|
|
22
|
+
# own connection. Returns the parsed response hash ({"ticket", "report"})
|
|
23
|
+
# or nil on failure.
|
|
24
|
+
def check(ip: nil, user_agent: nil, session_ref: nil, request_id: nil, request_context: nil,
|
|
25
|
+
detail: false, secret_key: nil)
|
|
26
|
+
secret_key ||= Reputable.configuration.signals_secret_key
|
|
27
|
+
return nil if secret_key.nil? || secret_key.empty?
|
|
28
|
+
|
|
29
|
+
uri = URI("#{Measurement.measurement_base_url}/v1/signals/check")
|
|
30
|
+
payload = {
|
|
31
|
+
ip: ip,
|
|
32
|
+
userAgent: user_agent,
|
|
33
|
+
sessionRef: session_ref,
|
|
34
|
+
requestId: request_id,
|
|
35
|
+
detail: detail || nil
|
|
36
|
+
}
|
|
37
|
+
payload[:request] = Measurement.normalize_request_context(request_context) if request_context
|
|
38
|
+
|
|
39
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
40
|
+
open_timeout: Reputable.configuration.connect_timeout,
|
|
41
|
+
read_timeout: Reputable.configuration.read_timeout) do |http|
|
|
42
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json",
|
|
43
|
+
"Authorization" => "Bearer #{secret_key}")
|
|
44
|
+
request.body = JSON.generate(payload.compact)
|
|
45
|
+
http.request(request)
|
|
46
|
+
end
|
|
47
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
48
|
+
|
|
49
|
+
data = JSON.parse(response.body)
|
|
50
|
+
data["report"] ||= data["result"]
|
|
51
|
+
data["ticket"] ||= data["proof"]
|
|
52
|
+
data
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
Reputable.logger&.warn "Reputable: signals check failed: #{e.message}"
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build a redirect-flow URL: send a visitor through the fleet and back to
|
|
59
|
+
# +return_url+ (which must be registered on the publishable key) with a
|
|
60
|
+
# signed ticket attached as ?reputable_ticket=...
|
|
61
|
+
def redirect_url(return_url:, key: nil, request_id: nil, session_ref: nil)
|
|
62
|
+
key ||= Reputable.configuration.signals_publishable_key
|
|
63
|
+
raise ArgumentError, "publishable key is required" if key.nil? || key.empty?
|
|
64
|
+
raise ArgumentError, "return_url is required" if return_url.nil? || return_url.empty?
|
|
65
|
+
|
|
66
|
+
params = { key: key, return: return_url, request_id: request_id, session: session_ref }.compact
|
|
67
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
68
|
+
"#{Measurement.measurement_base_url}/v1/signals/redirect?#{query}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Build a hosted challenge-page URL. The return URL must be registered on
|
|
72
|
+
# the publishable key. Clean traffic redirects back immediately; suspect
|
|
73
|
+
# traffic receives the key's configured CAPTCHA.
|
|
74
|
+
def challenge_page_url(return_url:, key: nil, request_id: nil, session_ref: nil)
|
|
75
|
+
key ||= Reputable.configuration.signals_publishable_key
|
|
76
|
+
raise ArgumentError, "publishable key is required" if key.nil? || key.empty?
|
|
77
|
+
raise ArgumentError, "return_url is required" if return_url.nil? || return_url.empty?
|
|
78
|
+
|
|
79
|
+
params = { key: key, return: return_url, request_id: request_id, session: session_ref }.compact
|
|
80
|
+
query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join("&")
|
|
81
|
+
"#{Measurement.measurement_base_url}/v1/signals/challenge-page?#{query}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/reputable/tracker.rb
CHANGED
|
@@ -68,6 +68,81 @@ module Reputable
|
|
|
68
68
|
true
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Track a trusted backend security event (risk_event_v1 envelope).
|
|
72
|
+
#
|
|
73
|
+
# This is transported through the request buffer with metadata.risk_event_v1
|
|
74
|
+
# so existing classification/rule pipelines can consume resulting tags.
|
|
75
|
+
def track_security_event(
|
|
76
|
+
event_type:,
|
|
77
|
+
ip: nil,
|
|
78
|
+
account_id: nil,
|
|
79
|
+
user_id: nil,
|
|
80
|
+
session_id: nil,
|
|
81
|
+
request_id: nil,
|
|
82
|
+
outcome: nil,
|
|
83
|
+
reason_code: nil,
|
|
84
|
+
context: {},
|
|
85
|
+
resource: nil,
|
|
86
|
+
metadata: {},
|
|
87
|
+
occurred_at_ms: nil,
|
|
88
|
+
path: "/_trusted/security_event",
|
|
89
|
+
**options
|
|
90
|
+
)
|
|
91
|
+
return false unless Reputable.enabled?
|
|
92
|
+
normalized_event_type = normalize_token(event_type)
|
|
93
|
+
return false if normalized_event_type.nil? || normalized_event_type.empty?
|
|
94
|
+
|
|
95
|
+
event_context = normalize_risk_context(context)
|
|
96
|
+
effective_ip = ip || event_context[:ip]
|
|
97
|
+
return false if effective_ip.nil? || effective_ip.to_s.strip.empty?
|
|
98
|
+
|
|
99
|
+
risk_event = build_risk_event_v1(
|
|
100
|
+
event_type: normalized_event_type,
|
|
101
|
+
occurred_at_ms: occurred_at_ms,
|
|
102
|
+
account_id: account_id || options[:account_id],
|
|
103
|
+
user_id: user_id || options[:user_id],
|
|
104
|
+
session_id: session_id || options[:session_id],
|
|
105
|
+
request_id: request_id || options[:request_id],
|
|
106
|
+
outcome: outcome,
|
|
107
|
+
reason_code: reason_code,
|
|
108
|
+
context: event_context,
|
|
109
|
+
resource: resource,
|
|
110
|
+
metadata: metadata
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
entry_metadata = merge_metadata(options[:metadata], metadata)
|
|
114
|
+
entry_metadata[:risk_event_v1] = risk_event
|
|
115
|
+
|
|
116
|
+
tags = Array(options[:tags]) + security_tags_for_event(risk_event)
|
|
117
|
+
|
|
118
|
+
entry = build_request_entry(effective_ip, path, options.merge(
|
|
119
|
+
method: options[:method] || "POST",
|
|
120
|
+
account_id: account_id || options[:account_id],
|
|
121
|
+
user_id: user_id || options[:user_id],
|
|
122
|
+
session_id: session_id || options[:session_id],
|
|
123
|
+
request_id: request_id || options[:request_id],
|
|
124
|
+
tags: tags,
|
|
125
|
+
metadata: entry_metadata
|
|
126
|
+
))
|
|
127
|
+
|
|
128
|
+
push_to_buffer(:request, entry)
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
Reputable.logger&.debug("Reputable track_security_event error: #{e.class} - #{e.message}")
|
|
131
|
+
false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Fire-and-forget variant of track_security_event.
|
|
135
|
+
def track_security_event_async(**kwargs)
|
|
136
|
+
return true unless Reputable.enabled?
|
|
137
|
+
|
|
138
|
+
Thread.new do
|
|
139
|
+
track_security_event(**kwargs)
|
|
140
|
+
rescue StandardError
|
|
141
|
+
# Silently ignore all errors in async thread
|
|
142
|
+
end
|
|
143
|
+
true
|
|
144
|
+
end
|
|
145
|
+
|
|
71
146
|
private
|
|
72
147
|
|
|
73
148
|
def build_request_entry(ip, path, options)
|
|
@@ -77,16 +152,114 @@ module Reputable
|
|
|
77
152
|
path: path,
|
|
78
153
|
query: options[:query],
|
|
79
154
|
method: options[:method] || "GET",
|
|
155
|
+
account_id: options[:account_id],
|
|
156
|
+
session_id: options[:session_id],
|
|
157
|
+
user_id: options[:user_id],
|
|
80
158
|
fingerprint: options[:fingerprint],
|
|
81
159
|
user_agent: options[:user_agent],
|
|
82
160
|
referer: options[:referer],
|
|
83
161
|
country: options[:country],
|
|
84
|
-
tags: options[:tags]
|
|
162
|
+
tags: Array(options[:tags]),
|
|
85
163
|
metadata: options[:metadata],
|
|
86
164
|
request_id: options[:request_id]
|
|
87
165
|
}.compact
|
|
88
166
|
end
|
|
89
167
|
|
|
168
|
+
def build_risk_event_v1(
|
|
169
|
+
event_type:,
|
|
170
|
+
occurred_at_ms: nil,
|
|
171
|
+
account_id: nil,
|
|
172
|
+
user_id: nil,
|
|
173
|
+
session_id: nil,
|
|
174
|
+
request_id: nil,
|
|
175
|
+
outcome: nil,
|
|
176
|
+
reason_code: nil,
|
|
177
|
+
context: {},
|
|
178
|
+
resource: nil,
|
|
179
|
+
metadata: {}
|
|
180
|
+
)
|
|
181
|
+
{
|
|
182
|
+
event_type: normalize_token(event_type),
|
|
183
|
+
occurred_at_ms: occurred_at_ms || (Time.now.to_f * 1000).to_i,
|
|
184
|
+
account_id: account_id,
|
|
185
|
+
user_id: user_id,
|
|
186
|
+
session_id: session_id,
|
|
187
|
+
request_id: request_id,
|
|
188
|
+
outcome: normalize_token(outcome),
|
|
189
|
+
reason_code: normalize_token(reason_code),
|
|
190
|
+
context: context,
|
|
191
|
+
resource: normalize_resource(resource),
|
|
192
|
+
metadata: metadata
|
|
193
|
+
}.compact
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_risk_context(context)
|
|
197
|
+
hash = context.respond_to?(:to_h) ? context.to_h : {}
|
|
198
|
+
{
|
|
199
|
+
ip: hash[:ip] || hash["ip"],
|
|
200
|
+
country: normalize_token(hash[:country] || hash["country"]),
|
|
201
|
+
asn: (hash[:asn] || hash["asn"])&.to_s,
|
|
202
|
+
proxy_class: normalize_token(hash[:proxy_class] || hash["proxy_class"]),
|
|
203
|
+
ja4: (hash[:ja4] || hash["ja4"])&.to_s,
|
|
204
|
+
ua_family: normalize_token(hash[:ua_family] || hash["ua_family"])
|
|
205
|
+
}.compact
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def normalize_resource(resource)
|
|
209
|
+
return nil if resource.nil?
|
|
210
|
+
|
|
211
|
+
hash = resource.respond_to?(:to_h) ? resource.to_h : {}
|
|
212
|
+
{
|
|
213
|
+
type: normalize_token(hash[:type] || hash["type"]),
|
|
214
|
+
id: (hash[:id] || hash["id"])&.to_s,
|
|
215
|
+
action: normalize_token(hash[:action] || hash["action"])
|
|
216
|
+
}.compact
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def security_tags_for_event(risk_event)
|
|
220
|
+
event_type = risk_event[:event_type]
|
|
221
|
+
outcome = risk_event[:outcome]
|
|
222
|
+
tags = ["security:event", "security:event:#{event_type}"]
|
|
223
|
+
case event_type
|
|
224
|
+
when "login_success"
|
|
225
|
+
tags << "trust:auth:login_success"
|
|
226
|
+
when "login_failed"
|
|
227
|
+
tags << "risk:suspicious:auth:login_failed"
|
|
228
|
+
when "permission_denied"
|
|
229
|
+
tags << "risk:suspicious:authz:permission_denied"
|
|
230
|
+
when "mfa_failed"
|
|
231
|
+
tags << "risk:suspicious:auth:mfa_failed"
|
|
232
|
+
when "account_locked"
|
|
233
|
+
tags << "risk:malicious:auth:account_locked"
|
|
234
|
+
when "password_reset"
|
|
235
|
+
tags << "trust:auth:password_reset"
|
|
236
|
+
when "account_unlock", "account_unlocked"
|
|
237
|
+
tags << "trust:auth:account_unlocked"
|
|
238
|
+
when "mfa_result"
|
|
239
|
+
if %w[fail failed].include?(outcome)
|
|
240
|
+
tags << "risk:suspicious:auth:mfa_failed"
|
|
241
|
+
elsif %w[success pass].include?(outcome)
|
|
242
|
+
tags << "trust:auth:mfa_success"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
tags << "security:outcome:#{outcome}" unless outcome.nil? || outcome.empty?
|
|
246
|
+
tags.compact.uniq
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def merge_metadata(*candidates)
|
|
250
|
+
merged = {}
|
|
251
|
+
candidates.compact.each do |item|
|
|
252
|
+
hash = item.respond_to?(:to_h) ? item.to_h : {}
|
|
253
|
+
hash.each { |k, v| merged[k.to_sym] = v }
|
|
254
|
+
end
|
|
255
|
+
merged
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def normalize_token(value)
|
|
259
|
+
return nil if value.nil?
|
|
260
|
+
value.to_s.strip.downcase.gsub(/[^a-z0-9_-]+/, "_").gsub(/\A_+|_+\z/, "")
|
|
261
|
+
end
|
|
262
|
+
|
|
90
263
|
def push_to_buffer(type, entry)
|
|
91
264
|
key = case type
|
|
92
265
|
when :request then Reputable.configuration.request_buffer_key
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Reputable
|
|
8
|
+
# Bot Signals — verify the signed ticket the widget (or the check endpoint)
|
|
9
|
+
# hands to your form. A ticket is a short-lived signed JWT carrying the
|
|
10
|
+
# visitor's signal report (network evidence, score, and whether a CAPTCHA was
|
|
11
|
+
# solved). Verify it offline against the published JWKS (no network call),
|
|
12
|
+
# with the hosted introspection endpoint as a fallback for environments that
|
|
13
|
+
# cannot fetch/cache the JWKS.
|
|
14
|
+
#
|
|
15
|
+
# What you do with the report is up to you: the "outcome" field is an
|
|
16
|
+
# advisory assessment derived from YOUR key's thresholds.
|
|
17
|
+
#
|
|
18
|
+
# @example Require a ticket on login
|
|
19
|
+
# report = Reputable.verify_ticket(params["reputable-ticket"], customer_id: "cus_123")
|
|
20
|
+
# if report && report["outcome"] == "allow"
|
|
21
|
+
# # proceed; report["challengePassed"] tells you if a CAPTCHA was solved
|
|
22
|
+
# else
|
|
23
|
+
# # reject / re-challenge — your call
|
|
24
|
+
# end
|
|
25
|
+
module Verify
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Verify a ticket and return its signal report hash (keys include
|
|
29
|
+
# "outcome", "challengePassed", "proxyScore", "rating", "signals", ...),
|
|
30
|
+
# or nil if invalid.
|
|
31
|
+
#
|
|
32
|
+
# Verification is offline-first (JWKS); if that yields nothing it falls back
|
|
33
|
+
# to the hosted introspection endpoint. When +session_ref+ is given it must
|
|
34
|
+
# match the ticket's subject.
|
|
35
|
+
def verify_ticket(ticket, **options)
|
|
36
|
+
claims = verify_ticket_claims(ticket, **options)
|
|
37
|
+
claims && (claims["report"] || claims["result"] || claims)
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
Reputable.logger&.warn "Reputable: verify_ticket failed: #{e.message}"
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Verify a ticket and return the complete signed claims. Set
|
|
44
|
+
# +network_fallback: false+ in latency-sensitive request paths to prohibit
|
|
45
|
+
# both JWKS fetching and hosted introspection.
|
|
46
|
+
def verify_ticket_claims(ticket, customer_id: nil, session_ref: nil, jwks: nil,
|
|
47
|
+
network_fallback: true, expected_key_id: nil,
|
|
48
|
+
expected_mode: nil, expected_return_origin: nil)
|
|
49
|
+
return nil if blank?(ticket)
|
|
50
|
+
|
|
51
|
+
offline_jwks = jwks || Reputable.configuration.measurement_result_jwks
|
|
52
|
+
return nil if !network_fallback && offline_jwks.nil?
|
|
53
|
+
|
|
54
|
+
claims = Measurement.verify_result_token(
|
|
55
|
+
ticket,
|
|
56
|
+
jwks: offline_jwks,
|
|
57
|
+
expected_customer_id: customer_id
|
|
58
|
+
)
|
|
59
|
+
claims ||= introspect(ticket, customer_id: customer_id, session_ref: session_ref) if network_fallback
|
|
60
|
+
return nil unless claims
|
|
61
|
+
|
|
62
|
+
return nil unless claim_matches?(claims, "sessionRef", session_ref, alternate: "sub")
|
|
63
|
+
return nil unless claim_matches?(claims, "keyId", expected_key_id)
|
|
64
|
+
return nil unless claim_matches?(claims, "mode", expected_mode)
|
|
65
|
+
return nil unless return_origin_matches?(claims["returnOrigin"], expected_return_origin)
|
|
66
|
+
|
|
67
|
+
claims
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Reputable.logger&.warn "Reputable: verify_ticket_claims failed: #{e.message}"
|
|
70
|
+
nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Legacy alias from the proof era.
|
|
74
|
+
def verify_proof(proof, **kwargs)
|
|
75
|
+
verify_ticket(proof, **kwargs)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Convenience predicate: did this ticket clear a CAPTCHA challenge?
|
|
79
|
+
def challenge_passed?(ticket, **kwargs)
|
|
80
|
+
report = verify_ticket(ticket, **kwargs)
|
|
81
|
+
return false unless report
|
|
82
|
+
|
|
83
|
+
!!(report["challengePassed"] || report[:challengePassed])
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Introspection fallback: POST the ticket to the hosted service, which
|
|
87
|
+
# verifies it server-side and returns the report. Always treat a non-valid
|
|
88
|
+
# response as a rejection.
|
|
89
|
+
def introspect(ticket, customer_id: nil, session_ref: nil)
|
|
90
|
+
base = Measurement.measurement_base_url
|
|
91
|
+
uri = URI("#{base}/v1/signals/introspect")
|
|
92
|
+
payload = { ticket: ticket, customerId: customer_id, sessionRef: session_ref }.compact
|
|
93
|
+
|
|
94
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
95
|
+
open_timeout: Reputable.configuration.connect_timeout,
|
|
96
|
+
read_timeout: Reputable.configuration.read_timeout) do |http|
|
|
97
|
+
request = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
|
|
98
|
+
request.body = JSON.generate(payload)
|
|
99
|
+
http.request(request)
|
|
100
|
+
end
|
|
101
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
102
|
+
|
|
103
|
+
data = JSON.parse(response.body)
|
|
104
|
+
return nil unless data["valid"]
|
|
105
|
+
|
|
106
|
+
{ "report" => data["report"] || data["result"], "sessionRef" => data["sessionRef"], "customerId" => data["customerId"] }
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Reputable.logger&.warn "Reputable: ticket introspection failed: #{e.message}"
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def present?(value)
|
|
113
|
+
!blank?(value)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def blank?(value)
|
|
117
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def claim_matches?(claims, key, expected, alternate: nil)
|
|
121
|
+
return true unless present?(expected)
|
|
122
|
+
|
|
123
|
+
values = [claims[key], alternate && claims[alternate]].compact
|
|
124
|
+
values.any? && values.all? { |value| value == expected }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def return_origin_matches?(actual, expected)
|
|
128
|
+
return true unless present?(expected)
|
|
129
|
+
|
|
130
|
+
normalized_actual = normalize_origin(actual)
|
|
131
|
+
normalized_expected = normalize_origin(expected)
|
|
132
|
+
normalized_actual && normalized_expected && normalized_actual == normalized_expected
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def normalize_origin(value)
|
|
136
|
+
uri = URI(value.to_s)
|
|
137
|
+
return nil unless %w[http https].include?(uri.scheme) && present?(uri.host)
|
|
138
|
+
|
|
139
|
+
default_port = (uri.scheme == "https" ? 443 : 80)
|
|
140
|
+
port = uri.port == default_port ? "" : ":#{uri.port}"
|
|
141
|
+
"#{uri.scheme}://#{uri.host.downcase}#{port}"
|
|
142
|
+
rescue URI::InvalidURIError
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/reputable/version.rb
CHANGED
data/lib/reputable.rb
CHANGED
|
@@ -7,6 +7,9 @@ require_relative "reputable/configuration"
|
|
|
7
7
|
require_relative "reputable/connection"
|
|
8
8
|
require_relative "reputable/tracker"
|
|
9
9
|
require_relative "reputable/reputation"
|
|
10
|
+
require_relative "reputable/measurement"
|
|
11
|
+
require_relative "reputable/verify"
|
|
12
|
+
require_relative "reputable/signals"
|
|
10
13
|
require_relative "reputable/blocked_page"
|
|
11
14
|
require_relative "reputable/middleware"
|
|
12
15
|
|
|
@@ -112,6 +115,14 @@ module Reputable
|
|
|
112
115
|
Tracker.track_request_async(ip: ip, path: path, **options)
|
|
113
116
|
end
|
|
114
117
|
|
|
118
|
+
def track_security_event(**kwargs)
|
|
119
|
+
Tracker.track_security_event(**kwargs)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def track_security_event_async(**kwargs)
|
|
123
|
+
Tracker.track_security_event_async(**kwargs)
|
|
124
|
+
end
|
|
125
|
+
|
|
115
126
|
# Delegate reputation methods to Reputation module
|
|
116
127
|
def apply_reputation(entity_type:, entity_id:, status:, **options)
|
|
117
128
|
Reputation.apply(entity_type: entity_type, entity_id: entity_id, status: status, **options)
|
|
@@ -187,6 +198,64 @@ module Reputable
|
|
|
187
198
|
Reputation.ignore_asn(asn, reason: reason, ttl: ttl, **metadata)
|
|
188
199
|
end
|
|
189
200
|
|
|
201
|
+
def measurement_token(**kwargs)
|
|
202
|
+
Measurement.measurement_token(**kwargs)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def measurement_collect_url(token)
|
|
206
|
+
Measurement.collect_url(token)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def measurement_redirect_url(token)
|
|
210
|
+
Measurement.redirect_url(token)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def verify_measurement_result_token(token, **kwargs)
|
|
214
|
+
Measurement.verify_result_token(token, **kwargs)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def verify_measurement_result(token, **kwargs)
|
|
218
|
+
Measurement.verify_result(token, **kwargs)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Bot Signals: verify the signed ticket submitted by the widget. Returns
|
|
222
|
+
# the signal report hash (incl. "outcome" and "challengePassed") or nil.
|
|
223
|
+
def verify_ticket(ticket, **kwargs)
|
|
224
|
+
Verify.verify_ticket(ticket, **kwargs)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Bot Signals: verify and return all signed ticket claims. This is useful
|
|
228
|
+
# when an application must bind a route pass to key, mode, session, or
|
|
229
|
+
# return origin rather than reading only the report.
|
|
230
|
+
def verify_ticket_claims(ticket, **kwargs)
|
|
231
|
+
Verify.verify_ticket_claims(ticket, **kwargs)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Legacy alias from the proof era.
|
|
235
|
+
def verify_proof(proof, **kwargs)
|
|
236
|
+
Verify.verify_ticket(proof, **kwargs)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Did the ticket clear a CAPTCHA challenge? Convenience predicate.
|
|
240
|
+
def ticket_challenge_passed?(ticket, **kwargs)
|
|
241
|
+
Verify.challenge_passed?(ticket, **kwargs)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Legacy alias from the proof era.
|
|
245
|
+
def proof_challenge_passed?(proof, **kwargs)
|
|
246
|
+
Verify.challenge_passed?(proof, **kwargs)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Bot Signals server API: signal report for an end user's request.
|
|
250
|
+
def signals_check(**kwargs)
|
|
251
|
+
Signals.check(**kwargs)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Bot Signals redirect-flow URL (publishable key + registered return URL).
|
|
255
|
+
def signals_redirect_url(**kwargs)
|
|
256
|
+
Signals.redirect_url(**kwargs)
|
|
257
|
+
end
|
|
258
|
+
|
|
190
259
|
# Generate a signed verification URL
|
|
191
260
|
# @param return_url [String] URL to redirect to after successful verification
|
|
192
261
|
# @param failure_url [String, nil] URL to redirect to on failure (optional)
|
data/reputable.gemspec
CHANGED
|
@@ -29,6 +29,7 @@ Gem::Specification.new do |spec|
|
|
|
29
29
|
|
|
30
30
|
spec.add_dependency "redis", ">= 4.0", "< 6.0"
|
|
31
31
|
spec.add_dependency "connection_pool", "~> 2.2"
|
|
32
|
+
spec.add_dependency "base64", ">= 0.2", "< 0.4"
|
|
32
33
|
|
|
33
34
|
spec.add_development_dependency "bundler", "~> 2.0"
|
|
34
35
|
spec.add_development_dependency "rake", "~> 13.0"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: reputable
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.26
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Reputable
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: redis
|
|
@@ -44,6 +44,26 @@ dependencies:
|
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
46
|
version: '2.2'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: base64
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.2'
|
|
54
|
+
- - "<"
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '0.4'
|
|
57
|
+
type: :runtime
|
|
58
|
+
prerelease: false
|
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0.2'
|
|
64
|
+
- - "<"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '0.4'
|
|
47
67
|
- !ruby/object:Gem::Dependency
|
|
48
68
|
name: bundler
|
|
49
69
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -132,10 +152,13 @@ files:
|
|
|
132
152
|
- lib/reputable/blocked_page.rb
|
|
133
153
|
- lib/reputable/configuration.rb
|
|
134
154
|
- lib/reputable/connection.rb
|
|
155
|
+
- lib/reputable/measurement.rb
|
|
135
156
|
- lib/reputable/middleware.rb
|
|
136
157
|
- lib/reputable/rails.rb
|
|
137
158
|
- lib/reputable/reputation.rb
|
|
159
|
+
- lib/reputable/signals.rb
|
|
138
160
|
- lib/reputable/tracker.rb
|
|
161
|
+
- lib/reputable/verify.rb
|
|
139
162
|
- lib/reputable/version.rb
|
|
140
163
|
- reputable.gemspec
|
|
141
164
|
homepage: https://github.com/reputable-click/reputable-rb
|