trackguard 0.27.1 → 0.29.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 +4 -4
- data/app/assets/javascripts/controllers/page_tracker_controller.js +5 -6
- data/app/assets/stylesheets/trackguard/admin.css +51 -0
- data/app/controllers/concerns/trackguard/page_tracker.rb +10 -3
- data/app/controllers/trackguard/admin/analytics_controller.rb +1 -0
- data/app/controllers/trackguard/admin/base_controller.rb +1 -1
- data/app/controllers/trackguard/admin/visitors_controller.rb +4 -1
- data/app/controllers/trackguard/page_views_controller.rb +1 -1
- data/app/helpers/trackguard/application_helper.rb +13 -5
- data/app/jobs/trackguard/detect_suspicious_visitors_job.rb +64 -10
- data/app/jobs/trackguard/hub/submit_blocked_request_job.rb +39 -0
- data/app/jobs/trackguard/hub/submit_page_view_job.rb +39 -0
- data/app/jobs/trackguard/track_blocked_request_job.rb +1 -14
- data/app/jobs/trackguard/track_page_view_job.rb +4 -23
- data/app/models/trackguard/page_view.rb +3 -0
- data/app/models/trackguard/visitor.rb +12 -4
- data/app/services/trackguard/track_blocked_request.rb +32 -0
- data/app/services/trackguard/track_page_view.rb +35 -0
- data/app/views/trackguard/admin/_stats_panel.html.erb +1 -1
- data/app/views/trackguard/admin/_visit_row.html.erb +34 -7
- data/config/importmap.rb +3 -1
- data/lib/generators/trackguard/install_generator.rb +4 -0
- data/lib/generators/trackguard/templates/add_suspicious_state_to_trackguard_visitors.rb +12 -0
- data/lib/generators/trackguard/templates/add_tracking_layer_to_trackguard_visits.rb +5 -0
- data/lib/generators/trackguard/templates/create_trackguard_visits.rb +1 -0
- data/lib/trackguard/adapters/base.rb +5 -4
- data/lib/trackguard/adapters/hub.rb +94 -0
- data/lib/trackguard/adapters/local.rb +3 -3
- data/lib/trackguard/engine.rb +6 -4
- data/lib/trackguard/trace_id_middleware.rb +14 -0
- data/lib/trackguard/version.rb +1 -1
- data/lib/trackguard.rb +11 -4
- data/trackguard.gemspec +1 -0
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1372d974514748b7cb553b4299b14de6345339c00947e24102be03ec7ac0be0d
|
|
4
|
+
data.tar.gz: 142f9a999003624989fa47ad7a422a5bc76c504dbb8666a8e676d49970490775
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e3f01bd73d5701b53f8e7a3ef37ab3c3a43b016dc72e9388c8b8f9db225412380889b8fd13ae1df8783231fcdb6fde828f7f6807854c6ef9fa0ee6a4924dc774
|
|
7
|
+
data.tar.gz: 9ff260dc894d2cbc70e69062bb878d394be1913faf4edd61dad5b9c10c41db2e68805985832ba8f4b7762af6a6483e0b6a0d9690447993d0c3f5ea542d8eb98c
|
|
@@ -11,8 +11,7 @@ export default class extends Controller {
|
|
|
11
11
|
window.addEventListener("hashchange", this.boundHash)
|
|
12
12
|
|
|
13
13
|
// Cover initial load if turbo:load already fired before connect()
|
|
14
|
-
|
|
15
|
-
this.trackCurrent(true)
|
|
14
|
+
this.trackCurrent()
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
disconnect() {
|
|
@@ -20,14 +19,14 @@ export default class extends Controller {
|
|
|
20
19
|
window.removeEventListener("hashchange", this.boundHash)
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
trackCurrent(
|
|
22
|
+
trackCurrent() {
|
|
24
23
|
const path = window.location.pathname + window.location.hash
|
|
25
24
|
if (path === this.lastTracked) return
|
|
26
25
|
this.lastTracked = path
|
|
27
|
-
this.track(path
|
|
26
|
+
this.track(path)
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
track(path
|
|
29
|
+
track(path) {
|
|
31
30
|
const token = document.querySelector('meta[name="csrf-token"]')?.content
|
|
32
31
|
const traceId = document.querySelector('meta[name="trace-id"]')?.content
|
|
33
32
|
const url = this.urlValue || document.querySelector('meta[name="trackguard-url"]')?.content
|
|
@@ -35,7 +34,7 @@ export default class extends Controller {
|
|
|
35
34
|
fetch(url, {
|
|
36
35
|
method: "POST",
|
|
37
36
|
headers: { "Content-Type": "application/json", "X-CSRF-Token": token || "" },
|
|
38
|
-
body: JSON.stringify({ path, trace_id: traceId, ref
|
|
37
|
+
body: JSON.stringify({ path, trace_id: traceId, ref })
|
|
39
38
|
})
|
|
40
39
|
}
|
|
41
40
|
}
|
|
@@ -240,6 +240,7 @@
|
|
|
240
240
|
/* ── Table ─────────────────────────────────────────────────────────── */
|
|
241
241
|
.tg-table {
|
|
242
242
|
width: 100%;
|
|
243
|
+
table-layout: fixed;
|
|
243
244
|
border-collapse: collapse;
|
|
244
245
|
font-size: 0.875rem;
|
|
245
246
|
margin-bottom: 2rem;
|
|
@@ -269,6 +270,14 @@
|
|
|
269
270
|
text-align: right;
|
|
270
271
|
color: #9ca3af;
|
|
271
272
|
font-weight: 400;
|
|
273
|
+
white-space: nowrap;
|
|
274
|
+
width: 3.5rem;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.tg-td--label {
|
|
278
|
+
overflow: hidden;
|
|
279
|
+
text-overflow: ellipsis;
|
|
280
|
+
white-space: nowrap;
|
|
272
281
|
}
|
|
273
282
|
|
|
274
283
|
.tg-td--break { word-break: break-all; }
|
|
@@ -291,6 +300,10 @@
|
|
|
291
300
|
background: rgba(239, 68, 68, 0.05);
|
|
292
301
|
}
|
|
293
302
|
|
|
303
|
+
.tg-row--suspicious {
|
|
304
|
+
background: rgba(251, 191, 36, 0.1);
|
|
305
|
+
}
|
|
306
|
+
|
|
294
307
|
.tg-row--whitelisted {
|
|
295
308
|
background: rgba(34, 197, 94, 0.05);
|
|
296
309
|
}
|
|
@@ -352,6 +365,14 @@
|
|
|
352
365
|
font-size: 0.875rem;
|
|
353
366
|
}
|
|
354
367
|
|
|
368
|
+
.tg-summary__layer {
|
|
369
|
+
width: 2.5rem;
|
|
370
|
+
text-align: center;
|
|
371
|
+
flex-shrink: 0;
|
|
372
|
+
color: #d1d5db;
|
|
373
|
+
font-size: 0.875rem;
|
|
374
|
+
}
|
|
375
|
+
|
|
355
376
|
.tg-summary__time {
|
|
356
377
|
font-size: 0.8125rem;
|
|
357
378
|
color: #9ca3af;
|
|
@@ -366,6 +387,13 @@
|
|
|
366
387
|
vertical-align: middle;
|
|
367
388
|
}
|
|
368
389
|
|
|
390
|
+
.tg-suspicious-icon {
|
|
391
|
+
width: 1rem;
|
|
392
|
+
height: 1rem;
|
|
393
|
+
color: #f59e0b;
|
|
394
|
+
vertical-align: middle;
|
|
395
|
+
}
|
|
396
|
+
|
|
369
397
|
.tg-whitelist-icon {
|
|
370
398
|
width: 1rem;
|
|
371
399
|
height: 1rem;
|
|
@@ -373,6 +401,27 @@
|
|
|
373
401
|
vertical-align: middle;
|
|
374
402
|
}
|
|
375
403
|
|
|
404
|
+
.tg-layer-badge {
|
|
405
|
+
display: inline-block;
|
|
406
|
+
font-size: 0.625rem;
|
|
407
|
+
font-weight: 600;
|
|
408
|
+
letter-spacing: 0.04em;
|
|
409
|
+
text-transform: uppercase;
|
|
410
|
+
border-radius: 3px;
|
|
411
|
+
padding: 0.125rem 0.3rem;
|
|
412
|
+
line-height: 1.4;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.tg-layer-badge--js {
|
|
416
|
+
background: #d1fae5;
|
|
417
|
+
color: #065f46;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.tg-layer-badge--backend {
|
|
421
|
+
background: #ffedd5;
|
|
422
|
+
color: #c2410c;
|
|
423
|
+
}
|
|
424
|
+
|
|
376
425
|
/* ── Detail panel ──────────────────────────────────────────────────── */
|
|
377
426
|
.tg-detail {
|
|
378
427
|
padding: 0 1rem 1rem 1rem;
|
|
@@ -428,6 +477,8 @@
|
|
|
428
477
|
|
|
429
478
|
.tg-dl__def--flagged { color: #dc2626; }
|
|
430
479
|
|
|
480
|
+
.tg-dl__def--suspicious { color: #d97706; }
|
|
481
|
+
|
|
431
482
|
.tg-dl__def--muted { color: #9ca3af; }
|
|
432
483
|
|
|
433
484
|
.tg-dl__def--whitelisted { color: #059669; }
|
|
@@ -15,7 +15,7 @@ module Trackguard
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
17
|
def set_trace_id
|
|
18
|
-
@trace_id =
|
|
18
|
+
@trace_id = request.env["trackguard.trace_id"]
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def track_page_view
|
|
@@ -30,11 +30,18 @@ module Trackguard
|
|
|
30
30
|
session_id: session.id.to_s,
|
|
31
31
|
trace_id: @trace_id,
|
|
32
32
|
source: extract_source,
|
|
33
|
-
|
|
34
|
-
http_method: request.request_method
|
|
33
|
+
tracking_layer: "backend",
|
|
34
|
+
http_method: request.request_method,
|
|
35
|
+
prefetch: turbo_prefetch?
|
|
35
36
|
)
|
|
36
37
|
end
|
|
37
38
|
|
|
39
|
+
def turbo_prefetch?
|
|
40
|
+
request.headers["Purpose"] == "prefetch" ||
|
|
41
|
+
request.headers["Sec-Purpose"]&.start_with?("prefetch") ||
|
|
42
|
+
request.headers["X-Sec-Purpose"]&.start_with?("prefetch")
|
|
43
|
+
end
|
|
44
|
+
|
|
38
45
|
def extract_source
|
|
39
46
|
raw = params[:ref].presence || params[:utm_source].presence
|
|
40
47
|
raw && raw.strip.downcase.first(64)
|
|
@@ -64,6 +64,7 @@ module Trackguard
|
|
|
64
64
|
{
|
|
65
65
|
path: view.path,
|
|
66
66
|
ip: view.visitor&.ip,
|
|
67
|
+
suspicious_state: view.visitor.suspicious_state,
|
|
67
68
|
flagged_at: view.visitor.flagged_at,
|
|
68
69
|
flagged_by: view.visitor.flagged_by,
|
|
69
70
|
whitelisted: view.visitor.whitelisted_ip&.active? || false,
|
|
@@ -16,9 +16,11 @@ module Trackguard
|
|
|
16
16
|
# rubocop:disable Metrics/AbcSize
|
|
17
17
|
def flag
|
|
18
18
|
if @visitor.update(
|
|
19
|
+
suspicious_state: "blocked",
|
|
19
20
|
flagged_at: Time.current,
|
|
20
21
|
flag_reason: params[:flag_reason].presence,
|
|
21
22
|
flagged_by: params[:flagged_by].presence || Visitor::FLAGGED_BY.first,
|
|
23
|
+
suspicious_since_at: nil,
|
|
22
24
|
name: params[:name].presence || BlockedUserAgent.matching_pattern(@visitor.user_agent)
|
|
23
25
|
)
|
|
24
26
|
respond_to do |format|
|
|
@@ -35,7 +37,8 @@ module Trackguard
|
|
|
35
37
|
# rubocop:enable Metrics/AbcSize
|
|
36
38
|
|
|
37
39
|
def unflag
|
|
38
|
-
@visitor.update!(
|
|
40
|
+
@visitor.update!(suspicious_state: "normal", suspicious_since_at: nil,
|
|
41
|
+
flagged_at: nil, flag_reason: nil, flagged_by: nil)
|
|
39
42
|
respond_to do |format|
|
|
40
43
|
format.html { redirect_back_or_to after_action_path }
|
|
41
44
|
format.json { render json: { status: "ok", ip: @visitor.ip } }
|
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
module Trackguard
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
def
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
def trackguard_header_tags
|
|
4
|
+
tags = [ tag.meta(name: "trace-id", content: @trace_id) ]
|
|
5
|
+
|
|
6
|
+
case Trackguard.adapter
|
|
7
|
+
when Trackguard::Adapters::Local
|
|
8
|
+
tags << tag.meta(name: "trackguard-url", content: trackguard.page_views_path)
|
|
9
|
+
when Trackguard::Adapters::Hub
|
|
10
|
+
if Rails.env.production?
|
|
11
|
+
tags << tag.script(src: "#{Trackguard.hub_url}/track.js", data: { api_key: Trackguard.hub_api_key })
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
safe_join(tags, "\n")
|
|
8
16
|
end
|
|
9
17
|
|
|
10
18
|
def trackguard_nav_links
|
|
@@ -24,7 +24,7 @@ module Trackguard
|
|
|
24
24
|
.joins(:visitor)
|
|
25
25
|
.merge(Visitor.unflagged)
|
|
26
26
|
.preload(visitor: :whitelisted_ip)
|
|
27
|
-
.select(:visitor_id, :session_id, :referer, :path, :trace_id)
|
|
27
|
+
.select(:visitor_id, :session_id, :referer, :path, :trace_id, :tracking_layer)
|
|
28
28
|
.group_by(&:visitor)
|
|
29
29
|
|
|
30
30
|
return if views_by_visitor.empty?
|
|
@@ -44,18 +44,29 @@ module Trackguard
|
|
|
44
44
|
|
|
45
45
|
name = name_from_ua(visitor.user_agent)
|
|
46
46
|
|
|
47
|
+
if visitor.suspicious?
|
|
48
|
+
new_views = PageView
|
|
49
|
+
.where(visitor: visitor)
|
|
50
|
+
.where("created_at > ?", visitor.suspicious_since_at)
|
|
51
|
+
.select(:tracking_layer, :trace_id)
|
|
52
|
+
result = evaluate_suspicious_escalation(visitor, new_views)
|
|
53
|
+
return if %i[blocked recovered].include?(result)
|
|
54
|
+
elsif views.any?(&:backend_layer?) && views.none?(&:js_layer?)
|
|
55
|
+
mark_suspicious!(visitor, name)
|
|
56
|
+
end
|
|
57
|
+
|
|
47
58
|
if count >= HARD_FLAG_THRESHOLD
|
|
48
|
-
|
|
59
|
+
mark_blocked!(visitor, "#{count} page views in 24h (hard flag threshold)", name: name)
|
|
49
60
|
return
|
|
50
61
|
end
|
|
51
62
|
|
|
52
63
|
if (reason = ua_flag_reason(visitor.user_agent))
|
|
53
|
-
|
|
64
|
+
mark_blocked!(visitor, reason, name: name)
|
|
54
65
|
return
|
|
55
66
|
end
|
|
56
67
|
|
|
57
68
|
if (path = probe_path_hit(views))
|
|
58
|
-
|
|
69
|
+
mark_blocked!(visitor, "probe path hit: #{path}", name: name)
|
|
59
70
|
return
|
|
60
71
|
end
|
|
61
72
|
|
|
@@ -64,7 +75,7 @@ module Trackguard
|
|
|
64
75
|
return if count < MIN_VIEWS
|
|
65
76
|
|
|
66
77
|
if views.all? { |pv| pv.session_id.nil? && pv.referer.nil? } && views.map(&:path).uniq.size == 1
|
|
67
|
-
|
|
78
|
+
mark_blocked!(visitor, "no session, no referrer, single path hit", name: name)
|
|
68
79
|
return
|
|
69
80
|
end
|
|
70
81
|
|
|
@@ -86,10 +97,34 @@ module Trackguard
|
|
|
86
97
|
|
|
87
98
|
return if score < FLAG_SCORE_THRESHOLD
|
|
88
99
|
|
|
89
|
-
|
|
100
|
+
mark_blocked!(visitor, reasons.join("; "), name: name)
|
|
90
101
|
end
|
|
91
102
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
|
|
92
103
|
|
|
104
|
+
def evaluate_suspicious_escalation(visitor, new_views)
|
|
105
|
+
return :unchanged if new_views.empty?
|
|
106
|
+
|
|
107
|
+
js_ids = new_views.select { |pv| pv.tracking_layer == "js" }.map(&:trace_id).compact
|
|
108
|
+
|
|
109
|
+
if any_unpaired_backend?(new_views, js_ids)
|
|
110
|
+
mark_blocked!(visitor, "continued backend-only visits since suspicious flag")
|
|
111
|
+
:blocked
|
|
112
|
+
elsif paired_backend_trace_ids(new_views, js_ids).any?
|
|
113
|
+
mark_normal!(visitor)
|
|
114
|
+
:recovered
|
|
115
|
+
else
|
|
116
|
+
:unchanged
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def any_unpaired_backend?(views, js_trace_ids)
|
|
121
|
+
views.any? { |pv| pv.backend_layer? && !js_trace_ids.include?(pv.trace_id) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def paired_backend_trace_ids(views, js_trace_ids)
|
|
125
|
+
views.select(&:backend_layer?).map(&:trace_id).compact & js_trace_ids
|
|
126
|
+
end
|
|
127
|
+
|
|
93
128
|
def flag_shared_trace_id_visitors(cutoff)
|
|
94
129
|
shared = PageView
|
|
95
130
|
.where(created_at: cutoff..)
|
|
@@ -108,8 +143,8 @@ module Trackguard
|
|
|
108
143
|
.where("wi.id IS NULL OR wi.expires_at <= ?", Time.current)
|
|
109
144
|
.distinct
|
|
110
145
|
.each do |visitor|
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
mark_blocked!(visitor, "trace_id shared across multiple visitors (cross-visitor bot detected)",
|
|
147
|
+
name: name_from_ua(visitor.user_agent))
|
|
113
148
|
end
|
|
114
149
|
end
|
|
115
150
|
|
|
@@ -125,8 +160,27 @@ module Trackguard
|
|
|
125
160
|
"malformed user-agent (duplicate)" if user_agent.scan("Mozilla/5.0").size > 1
|
|
126
161
|
end
|
|
127
162
|
|
|
128
|
-
def
|
|
129
|
-
visitor.
|
|
163
|
+
def mark_suspicious!(visitor, name)
|
|
164
|
+
return if visitor.suspicious? || visitor.blocked?
|
|
165
|
+
|
|
166
|
+
visitor.update!(suspicious_state: "suspicious", suspicious_since_at: Time.current, name: name)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def mark_normal!(visitor)
|
|
170
|
+
return if visitor.normal? || visitor.blocked?
|
|
171
|
+
|
|
172
|
+
visitor.update!(suspicious_state: "normal", suspicious_since_at: nil)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def mark_blocked!(visitor, reason, name: nil)
|
|
176
|
+
visitor.update!(
|
|
177
|
+
suspicious_state: "blocked",
|
|
178
|
+
flagged_at: Time.current,
|
|
179
|
+
flag_reason: reason,
|
|
180
|
+
flagged_by: "Recurring Job",
|
|
181
|
+
suspicious_since_at: nil,
|
|
182
|
+
name: name
|
|
183
|
+
)
|
|
130
184
|
end
|
|
131
185
|
|
|
132
186
|
def name_from_ua(user_agent)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Trackguard
|
|
7
|
+
module Hub
|
|
8
|
+
class SubmitBlockedRequestJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
def perform(ip:, user_agent:, path:, http_method:, block_reason:)
|
|
12
|
+
uri = URI("#{Trackguard.hub_url}/api/backend/blocked_requests")
|
|
13
|
+
body = {
|
|
14
|
+
blocked_request: {
|
|
15
|
+
ip: ip, user_agent: user_agent, path: path,
|
|
16
|
+
http_method: http_method, block_reason: block_reason
|
|
17
|
+
}
|
|
18
|
+
}.to_json
|
|
19
|
+
|
|
20
|
+
req = Net::HTTP::Post.new(uri)
|
|
21
|
+
req["X-Api-Key"] = Trackguard.hub_api_key
|
|
22
|
+
req["Authorization"] = "Bearer #{Trackguard.hub_secret_key}"
|
|
23
|
+
req["Content-Type"] = "application/json"
|
|
24
|
+
req.body = body
|
|
25
|
+
|
|
26
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: 3, read_timeout: 5 }
|
|
27
|
+
response = Net::HTTP.start(uri.hostname, uri.port, **opts) { |http| http.request(req) }
|
|
28
|
+
|
|
29
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
30
|
+
Rails.logger.warn(
|
|
31
|
+
"[Trackguard::Hub::SubmitBlockedRequestJob] Unexpected response #{response.code} for path=#{path}"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Rails.logger.warn("[Trackguard::Hub::SubmitBlockedRequestJob] Failed to submit blocked request: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Trackguard
|
|
7
|
+
module Hub
|
|
8
|
+
class SubmitPageViewJob < ApplicationJob
|
|
9
|
+
queue_as :default
|
|
10
|
+
|
|
11
|
+
def perform(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:, http_method:, **)
|
|
12
|
+
uri = URI("#{Trackguard.hub_url}/api/backend/page_views")
|
|
13
|
+
body = {
|
|
14
|
+
page_view: {
|
|
15
|
+
path: path, referer: referer, session_id: session_id, trace_id: trace_id,
|
|
16
|
+
source: source, http_method: http_method, ip: ip, user_agent: user_agent
|
|
17
|
+
}
|
|
18
|
+
}.to_json
|
|
19
|
+
|
|
20
|
+
req = Net::HTTP::Post.new(uri)
|
|
21
|
+
req["X-Api-Key"] = Trackguard.hub_api_key
|
|
22
|
+
req["Authorization"] = "Bearer #{Trackguard.hub_secret_key}"
|
|
23
|
+
req["Content-Type"] = "application/json"
|
|
24
|
+
req.body = body
|
|
25
|
+
|
|
26
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: 3, read_timeout: 5 }
|
|
27
|
+
response = Net::HTTP.start(uri.hostname, uri.port, **opts) { |http| http.request(req) }
|
|
28
|
+
|
|
29
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
30
|
+
Rails.logger.warn(
|
|
31
|
+
"[Trackguard::Hub::SubmitPageViewJob] Unexpected response #{response.code} for path=#{path}"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError => e
|
|
35
|
+
Rails.logger.warn("[Trackguard::Hub::SubmitPageViewJob] Failed to submit page view: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -5,20 +5,7 @@ module Trackguard
|
|
|
5
5
|
queue_as :default
|
|
6
6
|
|
|
7
7
|
def perform(ip:, user_agent:, path:, http_method:, block_reason:)
|
|
8
|
-
|
|
9
|
-
v.user_agent = user_agent
|
|
10
|
-
v.first_seen_at = Time.current
|
|
11
|
-
v.last_seen_at = Time.current
|
|
12
|
-
end
|
|
13
|
-
visitor.update!(last_seen_at: Time.current, user_agent: user_agent)
|
|
14
|
-
|
|
15
|
-
BlockedRequest.create!(
|
|
16
|
-
path: path,
|
|
17
|
-
user_agent: user_agent,
|
|
18
|
-
http_method: http_method,
|
|
19
|
-
block_reason: block_reason,
|
|
20
|
-
visitor: visitor
|
|
21
|
-
)
|
|
8
|
+
TrackBlockedRequest.call(ip:, user_agent:, path:, http_method:, block_reason:)
|
|
22
9
|
end
|
|
23
10
|
end
|
|
24
11
|
end
|
|
@@ -2,29 +2,10 @@ module Trackguard
|
|
|
2
2
|
class TrackPageViewJob < ApplicationJob
|
|
3
3
|
queue_as :default
|
|
4
4
|
|
|
5
|
-
def perform(path:, ip:, user_agent:, referer:, session_id: nil, trace_id: nil, source: nil,
|
|
6
|
-
http_method: nil)
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
visitor = Visitor.find_or_create_by!(ip: ip) do |v|
|
|
10
|
-
v.user_agent = user_agent
|
|
11
|
-
v.first_seen_at = Time.current
|
|
12
|
-
v.last_seen_at = Time.current
|
|
13
|
-
end
|
|
14
|
-
visitor.update!(last_seen_at: Time.current, user_agent: user_agent)
|
|
15
|
-
|
|
16
|
-
if initial && trace_id.present?
|
|
17
|
-
existing = PageView.find_by(trace_id: trace_id, visitor: visitor)
|
|
18
|
-
if existing
|
|
19
|
-
if path.include?("#") && !existing.path.include?("#") && path.start_with?("#{existing.path}#")
|
|
20
|
-
existing.update!(path: path)
|
|
21
|
-
end
|
|
22
|
-
return
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
PageView.create_with(source:, referer:, http_method:)
|
|
27
|
-
.find_or_create_by!(path:, user_agent:, session_id: hashed_session_id, trace_id:, visitor:)
|
|
5
|
+
def perform(path:, ip:, user_agent:, referer:, session_id: nil, trace_id: nil, source: nil,
|
|
6
|
+
tracking_layer: nil, http_method: nil)
|
|
7
|
+
TrackPageView.call(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:, tracking_layer:,
|
|
8
|
+
http_method:)
|
|
28
9
|
end
|
|
29
10
|
end
|
|
30
11
|
end
|
|
@@ -6,5 +6,8 @@ module Trackguard
|
|
|
6
6
|
scope :this_month, -> { where(created_at: 1.month.ago..) }
|
|
7
7
|
scope :with_referrer, -> { where.not(referer: [ nil, "" ]) }
|
|
8
8
|
scope :with_source, -> { where.not(source: [ nil, "" ]) }
|
|
9
|
+
|
|
10
|
+
def js_layer? = tracking_layer == "js"
|
|
11
|
+
def backend_layer? = tracking_layer == "backend"
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -2,17 +2,25 @@ module Trackguard
|
|
|
2
2
|
class Visitor < ApplicationRecord
|
|
3
3
|
self.table_name = "trackguard_visitors"
|
|
4
4
|
|
|
5
|
-
FLAGGED_BY
|
|
6
|
-
|
|
5
|
+
FLAGGED_BY = [ "User", "claw:auto", "Recurring Job", "Internal Automation", "External Automation" ].freeze
|
|
6
|
+
SUSPICIOUS_STATES = %w[normal suspicious blocked].freeze
|
|
7
|
+
CACHE_KEY = "trackguard/flagged_ips".freeze
|
|
7
8
|
|
|
8
9
|
validates :flagged_by, inclusion: { in: FLAGGED_BY }, allow_blank: true
|
|
10
|
+
validates :suspicious_state, inclusion: { in: SUSPICIOUS_STATES }
|
|
9
11
|
|
|
10
12
|
has_many :page_views, class_name: "Trackguard::PageView", foreign_key: "visitor_id"
|
|
11
13
|
has_many :blocked_requests, class_name: "Trackguard::BlockedRequest", foreign_key: "visitor_id"
|
|
12
14
|
has_one :whitelisted_ip, class_name: "Trackguard::WhitelistedIp", foreign_key: "visitor_id"
|
|
13
15
|
|
|
14
|
-
scope :unflagged,
|
|
15
|
-
scope :flagged,
|
|
16
|
+
scope :unflagged, -> { where(flagged_at: nil) }
|
|
17
|
+
scope :flagged, -> { where.not(flagged_at: nil) }
|
|
18
|
+
scope :suspicious_visitors, -> { where(suspicious_state: "suspicious") }
|
|
19
|
+
scope :blocked, -> { where(suspicious_state: "blocked") }
|
|
20
|
+
|
|
21
|
+
def normal? = suspicious_state == "normal"
|
|
22
|
+
def suspicious? = suspicious_state == "suspicious"
|
|
23
|
+
def blocked? = suspicious_state == "blocked"
|
|
16
24
|
|
|
17
25
|
def self.flagged?(ip)
|
|
18
26
|
flagged_ips = Rails.cache.fetch(CACHE_KEY, expires_in: 5.minutes) do
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Trackguard
|
|
4
|
+
class TrackBlockedRequest < ApplicationService
|
|
5
|
+
def initialize(ip:, user_agent:, path:, http_method:, block_reason:, visitor_scope: {})
|
|
6
|
+
@ip = ip
|
|
7
|
+
@user_agent = user_agent
|
|
8
|
+
@path = path
|
|
9
|
+
@http_method = http_method
|
|
10
|
+
@block_reason = block_reason
|
|
11
|
+
@visitor_scope = visitor_scope
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
visitor = Visitor.find_or_create_by!(ip: @ip, **@visitor_scope) do |v|
|
|
16
|
+
v.user_agent = @user_agent
|
|
17
|
+
v.first_seen_at = Time.current
|
|
18
|
+
v.last_seen_at = Time.current
|
|
19
|
+
end
|
|
20
|
+
visitor.update!(last_seen_at: Time.current, user_agent: @user_agent)
|
|
21
|
+
|
|
22
|
+
BlockedRequest.create!(
|
|
23
|
+
path: @path,
|
|
24
|
+
user_agent: @user_agent,
|
|
25
|
+
http_method: @http_method,
|
|
26
|
+
block_reason: @block_reason,
|
|
27
|
+
visitor: visitor,
|
|
28
|
+
**@visitor_scope
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Trackguard
|
|
2
|
+
class TrackPageView < ApplicationService
|
|
3
|
+
def initialize(path:, ip:, user_agent:, referer:, session_id: nil, trace_id: nil,
|
|
4
|
+
source: nil, tracking_layer: nil, http_method: nil, visitor_scope: {})
|
|
5
|
+
@path = path
|
|
6
|
+
@ip = ip
|
|
7
|
+
@user_agent = user_agent
|
|
8
|
+
@referer = referer
|
|
9
|
+
@session_id = session_id
|
|
10
|
+
@trace_id = trace_id
|
|
11
|
+
@source = source
|
|
12
|
+
@tracking_layer = tracking_layer
|
|
13
|
+
@http_method = http_method
|
|
14
|
+
@visitor_scope = visitor_scope
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
hashed_session_id = Digest::SHA256.hexdigest(@session_id) if @session_id.present?
|
|
19
|
+
|
|
20
|
+
visitor = Visitor.find_or_create_by!(ip: @ip, **@visitor_scope) do |v|
|
|
21
|
+
v.user_agent = @user_agent
|
|
22
|
+
v.first_seen_at = Time.current
|
|
23
|
+
v.last_seen_at = Time.current
|
|
24
|
+
end
|
|
25
|
+
visitor.update!(last_seen_at: Time.current, user_agent: @user_agent)
|
|
26
|
+
|
|
27
|
+
PageView.create!(
|
|
28
|
+
path: @path, user_agent: @user_agent, session_id: hashed_session_id,
|
|
29
|
+
trace_id: @trace_id, source: @source, referer: @referer,
|
|
30
|
+
http_method: @http_method, tracking_layer: @tracking_layer,
|
|
31
|
+
visitor: visitor, **@visitor_scope
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
<% visitor = pv.visitor %>
|
|
2
|
-
<% flagged = visitor&.
|
|
2
|
+
<% flagged = visitor&.blocked? %>
|
|
3
|
+
<% suspicious = visitor&.suspicious? %>
|
|
3
4
|
<% whitelisted = visitor&.whitelisted_ip&.active? %>
|
|
4
|
-
<% row_classes = [ ("tg-row--flagged" if flagged), ("tg-row--whitelisted" if whitelisted) ].compact.join(" ") %>
|
|
5
|
+
<% row_classes = [ ("tg-row--flagged" if flagged), ("tg-row--suspicious" if suspicious), ("tg-row--whitelisted" if whitelisted) ].compact.join(" ") %>
|
|
5
6
|
<tr class="<%= row_classes %>">
|
|
6
7
|
<td class="tg-td--bare">
|
|
7
8
|
<details>
|
|
@@ -11,9 +12,13 @@
|
|
|
11
12
|
<span class="tg-summary__path"><%= pv.path %></span>
|
|
12
13
|
<span class="tg-summary__flag">
|
|
13
14
|
<% if flagged %>
|
|
14
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-flag-icon" title="
|
|
15
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-flag-icon" title="Blocked">
|
|
15
16
|
<path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
|
|
16
17
|
</svg>
|
|
18
|
+
<% elsif suspicious %>
|
|
19
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="tg-suspicious-icon" title="Suspicious">
|
|
20
|
+
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
|
21
|
+
</svg>
|
|
17
22
|
<% else %>
|
|
18
23
|
—
|
|
19
24
|
<% end %>
|
|
@@ -27,6 +32,15 @@
|
|
|
27
32
|
—
|
|
28
33
|
<% end %>
|
|
29
34
|
</span>
|
|
35
|
+
<span class="tg-summary__layer">
|
|
36
|
+
<% if pv.tracking_layer == "js" %>
|
|
37
|
+
<span class="tg-layer-badge tg-layer-badge--js">js</span>
|
|
38
|
+
<% elsif pv.tracking_layer == "backend" %>
|
|
39
|
+
<span class="tg-layer-badge tg-layer-badge--backend">srv</span>
|
|
40
|
+
<% else %>
|
|
41
|
+
—
|
|
42
|
+
<% end %>
|
|
43
|
+
</span>
|
|
30
44
|
<span class="tg-summary__time"><%= pv.created_at.strftime("%b %-d, %H:%M") %></span>
|
|
31
45
|
</summary>
|
|
32
46
|
<div class="tg-detail">
|
|
@@ -46,7 +60,7 @@
|
|
|
46
60
|
class: "tg-btn tg-btn--whitelist",
|
|
47
61
|
data: { confirm: "Whitelist #{visitor.ip} for 7 days?" } %>
|
|
48
62
|
<% end %>
|
|
49
|
-
<% if flagged %>
|
|
63
|
+
<% if flagged || suspicious %>
|
|
50
64
|
<%= button_to "Unflag", _actions[:unflag][:url],
|
|
51
65
|
method: _actions[:unflag][:method],
|
|
52
66
|
params: _actions[:unflag][:params],
|
|
@@ -88,15 +102,19 @@
|
|
|
88
102
|
<span class="tg-dl__def"><%= visitor&.name.presence || "—" %></span>
|
|
89
103
|
</div>
|
|
90
104
|
<div class="tg-dl__row">
|
|
91
|
-
<span class="tg-dl__term">
|
|
105
|
+
<span class="tg-dl__term">Status</span>
|
|
92
106
|
<% if flagged %>
|
|
93
107
|
<span class="tg-dl__def tg-dl__def--flagged">
|
|
94
|
-
|
|
108
|
+
Blocked <%= visitor.flagged_at.strftime("%b %-d %Y, %H:%M") %>
|
|
95
109
|
<% if visitor.flagged_by.present? %> by <strong><%= visitor.flagged_by %></strong><% end %>
|
|
96
110
|
<% if visitor.flag_reason.present? %> — <%= visitor.flag_reason %><% end %>
|
|
97
111
|
</span>
|
|
112
|
+
<% elsif suspicious %>
|
|
113
|
+
<span class="tg-dl__def tg-dl__def--suspicious">
|
|
114
|
+
Suspicious since <%= visitor.suspicious_since_at.strftime("%b %-d %Y, %H:%M") %>
|
|
115
|
+
</span>
|
|
98
116
|
<% else %>
|
|
99
|
-
<span class="tg-dl__def tg-dl__def--muted">
|
|
117
|
+
<span class="tg-dl__def tg-dl__def--muted">Normal</span>
|
|
100
118
|
<% end %>
|
|
101
119
|
</div>
|
|
102
120
|
<div class="tg-dl__row">
|
|
@@ -130,6 +148,15 @@
|
|
|
130
148
|
<span class="tg-dl__term">Source</span>
|
|
131
149
|
<span class="tg-dl__def"><%= pv.source.presence || "—" %></span>
|
|
132
150
|
</div>
|
|
151
|
+
<div class="tg-dl__row">
|
|
152
|
+
<span class="tg-dl__term">Layer</span>
|
|
153
|
+
<span class="tg-dl__def">
|
|
154
|
+
<% if pv.tracking_layer == "js" %>JS (client)
|
|
155
|
+
<% elsif pv.tracking_layer == "backend" %>Backend (server)
|
|
156
|
+
<% else %><span class="tg-dl__def--muted">—</span>
|
|
157
|
+
<% end %>
|
|
158
|
+
</span>
|
|
159
|
+
</div>
|
|
133
160
|
</div>
|
|
134
161
|
</div>
|
|
135
162
|
</div>
|
data/config/importmap.rb
CHANGED
|
@@ -18,6 +18,10 @@ module Trackguard
|
|
|
18
18
|
migration_template "create_trackguard_blocked_user_agents.rb",
|
|
19
19
|
"db/migrate/create_trackguard_blocked_user_agents.rb"
|
|
20
20
|
migration_template "create_trackguard_blocked_paths.rb", "db/migrate/create_trackguard_blocked_paths.rb"
|
|
21
|
+
migration_template "add_tracking_layer_to_trackguard_visits.rb",
|
|
22
|
+
"db/migrate/add_tracking_layer_to_trackguard_visits.rb"
|
|
23
|
+
migration_template "add_suspicious_state_to_trackguard_visitors.rb",
|
|
24
|
+
"db/migrate/add_suspicious_state_to_trackguard_visitors.rb"
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def print_next_steps
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
class AddSuspiciousStateToTrackguardVisitors < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
add_column :trackguard_visitors, :suspicious_state, :string, null: false, default: "normal"
|
|
4
|
+
add_column :trackguard_visitors, :suspicious_since_at, :datetime
|
|
5
|
+
|
|
6
|
+
reversible do |dir|
|
|
7
|
+
dir.up do
|
|
8
|
+
execute "UPDATE trackguard_visitors SET suspicious_state = 'blocked' WHERE flagged_at IS NOT NULL"
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -10,6 +10,7 @@ class CreateTrackguardVisits < ActiveRecord::Migration[<%= ActiveRecord::Migrati
|
|
|
10
10
|
t.string :source
|
|
11
11
|
t.string :block_reason
|
|
12
12
|
t.string :http_method
|
|
13
|
+
t.string :tracking_layer
|
|
13
14
|
t.references :visitor, null: false, foreign_key: { to_table: :trackguard_visitors }
|
|
14
15
|
t.datetime :created_at, null: false
|
|
15
16
|
end
|
|
@@ -8,7 +8,8 @@ module Trackguard
|
|
|
8
8
|
def whitelisted_ip?(ip) = raise NotImplementedError, "#{self.class}#whitelisted_ip?"
|
|
9
9
|
def flagged_visitor?(ip) = raise NotImplementedError, "#{self.class}#flagged_visitor?"
|
|
10
10
|
|
|
11
|
-
def track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
|
|
11
|
+
def track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:, tracking_layer:,
|
|
12
|
+
http_method:, prefetch: false)
|
|
12
13
|
return if blocked_user_agent?(user_agent)
|
|
13
14
|
return if blocked_path?(path)
|
|
14
15
|
return if path.blank? || path.start_with?(Trackguard.admin_path)
|
|
@@ -16,7 +17,7 @@ module Trackguard
|
|
|
16
17
|
perform_track_page_view(
|
|
17
18
|
path: path, ip: ip, user_agent: user_agent, referer: referer,
|
|
18
19
|
session_id: session_id, trace_id: trace_id, source: source,
|
|
19
|
-
|
|
20
|
+
tracking_layer: tracking_layer, http_method: http_method, prefetch: prefetch
|
|
20
21
|
)
|
|
21
22
|
end
|
|
22
23
|
|
|
@@ -26,8 +27,8 @@ module Trackguard
|
|
|
26
27
|
|
|
27
28
|
protected
|
|
28
29
|
|
|
29
|
-
def perform_track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
|
|
30
|
-
http_method:)
|
|
30
|
+
def perform_track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
|
|
31
|
+
tracking_layer:, http_method:, prefetch: false)
|
|
31
32
|
raise NotImplementedError, "#{self.class}#perform_track_page_view"
|
|
32
33
|
end
|
|
33
34
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Trackguard
|
|
7
|
+
module Adapters
|
|
8
|
+
class Hub < Base
|
|
9
|
+
CACHE_KEY = "trackguard/hub_rules"
|
|
10
|
+
STALE_KEY = "trackguard/hub_rules/stale"
|
|
11
|
+
ETAG_KEY = "trackguard/hub_rules/etag"
|
|
12
|
+
|
|
13
|
+
def blocked_user_agent?(user_agent)
|
|
14
|
+
rules.fetch(:blocked_user_agents, []).any? do |p|
|
|
15
|
+
user_agent.to_s.downcase.include?(p.downcase)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def blocked_path?(path)
|
|
20
|
+
rules.fetch(:blocked_paths, []).any? do |p|
|
|
21
|
+
path.to_s.downcase.include?(p.downcase)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def whitelisted_ip?(ip)
|
|
26
|
+
rules.fetch(:whitelisted_ips, []).include?(ip)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def flagged_visitor?(ip)
|
|
30
|
+
rules.fetch(:flagged_ips, []).include?(ip)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def track_blocked_request(ip:, user_agent:, path:, http_method:, block_reason:)
|
|
34
|
+
Trackguard::Hub::SubmitBlockedRequestJob.perform_later(
|
|
35
|
+
ip: ip, user_agent: user_agent, path: path, http_method: http_method, block_reason: block_reason
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
def perform_track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
|
|
42
|
+
tracking_layer:, http_method:, prefetch: false)
|
|
43
|
+
return if prefetch
|
|
44
|
+
|
|
45
|
+
Trackguard::Hub::SubmitPageViewJob.perform_later(
|
|
46
|
+
path: path, ip: ip, user_agent: user_agent, referer: referer,
|
|
47
|
+
session_id: session_id, trace_id: trace_id, source: source,
|
|
48
|
+
tracking_layer: tracking_layer, http_method: http_method
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def rules
|
|
55
|
+
Rails.cache.fetch(CACHE_KEY, expires_in: Trackguard.hub_rules_ttl, race_condition_ttl: 10) do
|
|
56
|
+
fresh = fetch_rules_from_hub
|
|
57
|
+
Rails.cache.write(STALE_KEY, fresh, expires_in: 24.hours)
|
|
58
|
+
fresh
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
Rails.logger.warn("[Trackguard::Adapters::Hub] Failed to fetch rules: #{e.message}")
|
|
62
|
+
Rails.cache.read(STALE_KEY) || {}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fetch_rules_from_hub
|
|
66
|
+
uri = URI("#{Trackguard.hub_url}/api/backend/rules")
|
|
67
|
+
response = execute_http(uri, build_request(uri))
|
|
68
|
+
|
|
69
|
+
return Rails.cache.read(STALE_KEY) || {} if response.is_a?(Net::HTTPNotModified)
|
|
70
|
+
|
|
71
|
+
raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
72
|
+
|
|
73
|
+
fresh = JSON.parse(response.body, symbolize_names: true)
|
|
74
|
+
Rails.cache.write(ETAG_KEY, response["ETag"], expires_in: 24.hours) if response["ETag"]
|
|
75
|
+
fresh
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def build_request(uri)
|
|
79
|
+
req = Net::HTTP::Get.new(uri)
|
|
80
|
+
req["Authorization"] = "Bearer #{Trackguard.hub_secret_key}"
|
|
81
|
+
req["X-Api-Key"] = Trackguard.hub_api_key
|
|
82
|
+
req["Accept"] = "application/json"
|
|
83
|
+
etag = Rails.cache.read(ETAG_KEY)
|
|
84
|
+
req["If-None-Match"] = etag if etag
|
|
85
|
+
req
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def execute_http(uri, request)
|
|
89
|
+
opts = { use_ssl: uri.scheme == "https", open_timeout: 3, read_timeout: 5 }
|
|
90
|
+
Net::HTTP.start(uri.hostname, uri.port, **opts) { |http| http.request(request) }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -31,8 +31,8 @@ module Trackguard
|
|
|
31
31
|
|
|
32
32
|
protected
|
|
33
33
|
|
|
34
|
-
def perform_track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
|
|
35
|
-
http_method:)
|
|
34
|
+
def perform_track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
|
|
35
|
+
tracking_layer:, http_method:, prefetch: false)
|
|
36
36
|
TrackPageViewJob.perform_later(
|
|
37
37
|
path: path,
|
|
38
38
|
ip: ip,
|
|
@@ -41,7 +41,7 @@ module Trackguard
|
|
|
41
41
|
session_id: session_id,
|
|
42
42
|
trace_id: trace_id,
|
|
43
43
|
source: source,
|
|
44
|
-
|
|
44
|
+
tracking_layer: tracking_layer,
|
|
45
45
|
http_method: http_method
|
|
46
46
|
)
|
|
47
47
|
end
|
data/lib/trackguard/engine.rb
CHANGED
|
@@ -4,10 +4,8 @@ module Trackguard
|
|
|
4
4
|
class Engine < ::Rails::Engine
|
|
5
5
|
isolate_namespace Trackguard
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
helper Trackguard::ApplicationHelper
|
|
10
|
-
end
|
|
7
|
+
config.to_prepare do
|
|
8
|
+
ActionController::Base.helper Trackguard::ApplicationHelper
|
|
11
9
|
end
|
|
12
10
|
|
|
13
11
|
initializer "trackguard.assets" do |app|
|
|
@@ -18,6 +16,10 @@ module Trackguard
|
|
|
18
16
|
app.config.importmap.paths << root.join("config/importmap.rb") if app.config.respond_to?(:importmap)
|
|
19
17
|
end
|
|
20
18
|
|
|
19
|
+
initializer "trackguard.trace_id_middleware" do |app|
|
|
20
|
+
app.middleware.use Trackguard::TraceIdMiddleware
|
|
21
|
+
end
|
|
22
|
+
|
|
21
23
|
config.after_initialize do
|
|
22
24
|
Trackguard::RackAttack.configure
|
|
23
25
|
end
|
data/lib/trackguard/version.rb
CHANGED
data/lib/trackguard.rb
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
require "trackguard/version"
|
|
4
4
|
require "trackguard/engine"
|
|
5
5
|
require "trackguard/rack_attack"
|
|
6
|
+
require "trackguard/trace_id_middleware"
|
|
6
7
|
require "trackguard/adapters/base"
|
|
7
8
|
require "trackguard/adapters/local"
|
|
9
|
+
require "trackguard/adapters/hub"
|
|
8
10
|
|
|
9
11
|
module Trackguard
|
|
10
12
|
class << self
|
|
11
|
-
attr_writer :authenticate_admin_with, :admin_layout, :admin_path, :back_label, :
|
|
12
|
-
:throttle_period
|
|
13
|
+
attr_writer :authenticate_admin_with, :admin_layout, :admin_path, :back_label, :local_api_token, :throttle_limit,
|
|
14
|
+
:throttle_period, :hub_secret_key, :hub_api_key, :hub_rules_ttl
|
|
15
|
+
attr_accessor :hub_url
|
|
13
16
|
|
|
14
17
|
def authenticate_admin_with
|
|
15
18
|
@authenticate_admin_with ||= proc {}
|
|
@@ -27,8 +30,8 @@ module Trackguard
|
|
|
27
30
|
@back_label ||= "Back to app"
|
|
28
31
|
end
|
|
29
32
|
|
|
30
|
-
def
|
|
31
|
-
@
|
|
33
|
+
def local_api_token
|
|
34
|
+
@local_api_token.to_s
|
|
32
35
|
end
|
|
33
36
|
|
|
34
37
|
def throttle_limit
|
|
@@ -39,6 +42,10 @@ module Trackguard
|
|
|
39
42
|
@throttle_period ||= 60
|
|
40
43
|
end
|
|
41
44
|
|
|
45
|
+
def hub_secret_key = @hub_secret_key.to_s
|
|
46
|
+
def hub_api_key = @hub_api_key.to_s
|
|
47
|
+
def hub_rules_ttl = @hub_rules_ttl ||= 5.minutes
|
|
48
|
+
|
|
42
49
|
def adapter
|
|
43
50
|
@adapter ||= Trackguard::Adapters::Local.new
|
|
44
51
|
end
|
data/trackguard.gemspec
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: trackguard
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.29.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Krzysztof Rygielski
|
|
@@ -59,6 +59,8 @@ files:
|
|
|
59
59
|
- app/controllers/trackguard/page_views_controller.rb
|
|
60
60
|
- app/helpers/trackguard/application_helper.rb
|
|
61
61
|
- app/jobs/trackguard/detect_suspicious_visitors_job.rb
|
|
62
|
+
- app/jobs/trackguard/hub/submit_blocked_request_job.rb
|
|
63
|
+
- app/jobs/trackguard/hub/submit_page_view_job.rb
|
|
62
64
|
- app/jobs/trackguard/track_blocked_request_job.rb
|
|
63
65
|
- app/jobs/trackguard/track_page_view_job.rb
|
|
64
66
|
- app/models/trackguard/blocked_path.rb
|
|
@@ -70,6 +72,8 @@ files:
|
|
|
70
72
|
- app/models/trackguard/whitelisted_ip.rb
|
|
71
73
|
- app/services/trackguard/analytics_query.rb
|
|
72
74
|
- app/services/trackguard/application_service.rb
|
|
75
|
+
- app/services/trackguard/track_blocked_request.rb
|
|
76
|
+
- app/services/trackguard/track_page_view.rb
|
|
73
77
|
- app/views/layouts/trackguard/admin.html.erb
|
|
74
78
|
- app/views/trackguard/admin/_nav.html.erb
|
|
75
79
|
- app/views/trackguard/admin/_stats_panel.html.erb
|
|
@@ -82,6 +86,8 @@ files:
|
|
|
82
86
|
- config/importmap.rb
|
|
83
87
|
- config/routes.rb
|
|
84
88
|
- lib/generators/trackguard/install_generator.rb
|
|
89
|
+
- lib/generators/trackguard/templates/add_suspicious_state_to_trackguard_visitors.rb
|
|
90
|
+
- lib/generators/trackguard/templates/add_tracking_layer_to_trackguard_visits.rb
|
|
85
91
|
- lib/generators/trackguard/templates/create_trackguard_blocked_paths.rb
|
|
86
92
|
- lib/generators/trackguard/templates/create_trackguard_blocked_user_agents.rb
|
|
87
93
|
- lib/generators/trackguard/templates/create_trackguard_visitors.rb
|
|
@@ -90,9 +96,11 @@ files:
|
|
|
90
96
|
- lib/tasks/trackguard.rake
|
|
91
97
|
- lib/trackguard.rb
|
|
92
98
|
- lib/trackguard/adapters/base.rb
|
|
99
|
+
- lib/trackguard/adapters/hub.rb
|
|
93
100
|
- lib/trackguard/adapters/local.rb
|
|
94
101
|
- lib/trackguard/engine.rb
|
|
95
102
|
- lib/trackguard/rack_attack.rb
|
|
103
|
+
- lib/trackguard/trace_id_middleware.rb
|
|
96
104
|
- lib/trackguard/version.rb
|
|
97
105
|
- trackguard.gemspec
|
|
98
106
|
homepage: https://github.com/riggy/trackguard
|
|
@@ -100,6 +108,7 @@ licenses:
|
|
|
100
108
|
- MIT
|
|
101
109
|
metadata:
|
|
102
110
|
rubygems_mfa_required: 'true'
|
|
111
|
+
changelog_uri: https://github.com/riggy/trackguard/blob/main/CHANGELOG.md
|
|
103
112
|
rdoc_options: []
|
|
104
113
|
require_paths:
|
|
105
114
|
- lib
|