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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/javascripts/controllers/page_tracker_controller.js +5 -6
  3. data/app/assets/stylesheets/trackguard/admin.css +51 -0
  4. data/app/controllers/concerns/trackguard/page_tracker.rb +10 -3
  5. data/app/controllers/trackguard/admin/analytics_controller.rb +1 -0
  6. data/app/controllers/trackguard/admin/base_controller.rb +1 -1
  7. data/app/controllers/trackguard/admin/visitors_controller.rb +4 -1
  8. data/app/controllers/trackguard/page_views_controller.rb +1 -1
  9. data/app/helpers/trackguard/application_helper.rb +13 -5
  10. data/app/jobs/trackguard/detect_suspicious_visitors_job.rb +64 -10
  11. data/app/jobs/trackguard/hub/submit_blocked_request_job.rb +39 -0
  12. data/app/jobs/trackguard/hub/submit_page_view_job.rb +39 -0
  13. data/app/jobs/trackguard/track_blocked_request_job.rb +1 -14
  14. data/app/jobs/trackguard/track_page_view_job.rb +4 -23
  15. data/app/models/trackguard/page_view.rb +3 -0
  16. data/app/models/trackguard/visitor.rb +12 -4
  17. data/app/services/trackguard/track_blocked_request.rb +32 -0
  18. data/app/services/trackguard/track_page_view.rb +35 -0
  19. data/app/views/trackguard/admin/_stats_panel.html.erb +1 -1
  20. data/app/views/trackguard/admin/_visit_row.html.erb +34 -7
  21. data/config/importmap.rb +3 -1
  22. data/lib/generators/trackguard/install_generator.rb +4 -0
  23. data/lib/generators/trackguard/templates/add_suspicious_state_to_trackguard_visitors.rb +12 -0
  24. data/lib/generators/trackguard/templates/add_tracking_layer_to_trackguard_visits.rb +5 -0
  25. data/lib/generators/trackguard/templates/create_trackguard_visits.rb +1 -0
  26. data/lib/trackguard/adapters/base.rb +5 -4
  27. data/lib/trackguard/adapters/hub.rb +94 -0
  28. data/lib/trackguard/adapters/local.rb +3 -3
  29. data/lib/trackguard/engine.rb +6 -4
  30. data/lib/trackguard/trace_id_middleware.rb +14 -0
  31. data/lib/trackguard/version.rb +1 -1
  32. data/lib/trackguard.rb +11 -4
  33. data/trackguard.gemspec +1 -0
  34. metadata +10 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e9860b115149813503e2063f8ed18c86fe82510c392a12a15eec6ecda39abbc
4
- data.tar.gz: 34f0816d3a6193f71b985336fec9076e8c7889ff583c60165b0ddbcfc7a118da
3
+ metadata.gz: 1372d974514748b7cb553b4299b14de6345339c00947e24102be03ec7ac0be0d
4
+ data.tar.gz: 142f9a999003624989fa47ad7a422a5bc76c504dbb8666a8e676d49970490775
5
5
  SHA512:
6
- metadata.gz: 8bccd308249f6c1dc2413abeb2b4c0e133736b3e75bd8f325ca64d042e9989c6b6c826d2959d92912965974062cb73b031f74413587bb2439b48dc1eef5e1a50
7
- data.tar.gz: 5d56636a21dd20f458b48047ea2749ad54994e11db96c70574148b267440a0da0bd05706cd6ca9d22e1192b1d7f7b498c0733a75bed260a088978e08886f152e
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
- // Pass initial=true so the job can deduplicate/enrich against the server-side record
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(initial = false) {
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, initial)
26
+ this.track(path)
28
27
  }
29
28
 
30
- track(path, initial = false) {
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, initial })
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 = SecureRandom.uuid
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
- initial: false,
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,
@@ -12,7 +12,7 @@ module Trackguard
12
12
  end
13
13
 
14
14
  def valid_api_token?
15
- expected = Trackguard.api_token
15
+ expected = Trackguard.local_api_token
16
16
  return false unless expected.present?
17
17
 
18
18
  token = request.headers["Authorization"]&.then { |h| h[/\ABearer (.+)\z/, 1] }
@@ -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!(flagged_at: nil, flag_reason: nil, flagged_by: nil)
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 } }
@@ -9,7 +9,7 @@ module Trackguard
9
9
  session_id: session.id.to_s,
10
10
  trace_id: params[:trace_id].to_s.presence,
11
11
  source: params[:ref].to_s.strip.downcase.first(64).presence,
12
- initial: params[:initial] == true,
12
+ tracking_layer: "js",
13
13
  http_method: "GET"
14
14
  )
15
15
 
@@ -1,10 +1,18 @@
1
1
  module Trackguard
2
2
  module ApplicationHelper
3
- def trackguard_meta_tags
4
- safe_join([
5
- tag.meta(name: "trackguard-url", content: trackguard.page_views_path),
6
- tag.meta(name: "trace-id", content: @trace_id)
7
- ], "\n")
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
- flag!(visitor, "#{count} page views in 24h (hard flag threshold)", name: name)
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
- flag!(visitor, reason, name: name)
64
+ mark_blocked!(visitor, reason, name: name)
54
65
  return
55
66
  end
56
67
 
57
68
  if (path = probe_path_hit(views))
58
- flag!(visitor, "probe path hit: #{path}", name: name)
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
- flag!(visitor, "no session, no referrer, single path hit", name: name)
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
- flag!(visitor, reasons.join("; "), name: name)
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
- flag!(visitor, "trace_id shared across multiple visitors (cross-visitor bot detected)",
112
- name: name_from_ua(visitor.user_agent))
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 flag!(visitor, reason, name: nil)
129
- visitor.update!(flagged_at: Time.current, flag_reason: reason, flagged_by: "claw:auto", name: name)
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
- visitor = Visitor.find_or_create_by!(ip: ip) do |v|
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, initial: false,
6
- http_method: nil)
7
- hashed_session_id = Digest::SHA256.hexdigest(session_id) if session_id.present?
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 = [ "User", "claw:auto" ].freeze
6
- CACHE_KEY = "trackguard/flagged_ips".freeze
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, -> { where(flagged_at: nil) }
15
- scope :flagged, -> { where.not(flagged_at: nil) }
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
@@ -15,7 +15,7 @@
15
15
  <tbody>
16
16
  <% rows.each do |label, count| %>
17
17
  <tr>
18
- <td class="tg-td"><%= label %></td>
18
+ <td class="tg-td tg-td--label"><%= label %></td>
19
19
  <td class="tg-td tg-td--num"><%= count %></td>
20
20
  </tr>
21
21
  <% end %>
@@ -1,7 +1,8 @@
1
1
  <% visitor = pv.visitor %>
2
- <% flagged = visitor&.flagged_at.present? %>
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="Flagged">
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">Flag status</span>
105
+ <span class="tg-dl__term">Status</span>
92
106
  <% if flagged %>
93
107
  <span class="tg-dl__def tg-dl__def--flagged">
94
- Flagged <%= visitor.flagged_at.strftime("%b %-d %Y, %H:%M") %>
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">Not flagged</span>
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
@@ -1 +1,3 @@
1
- pin_all_from File.expand_path("../app/assets/javascripts/controllers", __dir__), under: "controllers"
1
+ if Trackguard.adapter.is_a?(Trackguard::Adapters::Local)
2
+ pin_all_from File.expand_path("../app/assets/javascripts/controllers", __dir__), under: "controllers"
3
+ end
@@ -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
@@ -0,0 +1,5 @@
1
+ class AddTrackingLayerToTrackguardVisits < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :trackguard_visits, :tracking_layer, :string
4
+ end
5
+ 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:, initial:, http_method:)
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
- initial: initial, http_method: http_method
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:, initial:,
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:, initial:,
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
- initial: initial,
44
+ tracking_layer: tracking_layer,
45
45
  http_method: http_method
46
46
  )
47
47
  end
@@ -4,10 +4,8 @@ module Trackguard
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Trackguard
6
6
 
7
- initializer "trackguard.helpers" do
8
- ActiveSupport.on_load(:action_controller) do
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
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trackguard
4
+ class TraceIdMiddleware
5
+ def initialize(app)
6
+ @app = app
7
+ end
8
+
9
+ def call(env)
10
+ env["trackguard.trace_id"] = SecureRandom.uuid
11
+ @app.call(env)
12
+ end
13
+ end
14
+ end
@@ -1,3 +1,3 @@
1
1
  module Trackguard
2
- VERSION = "0.27.1".freeze
2
+ VERSION = "0.29.0".freeze
3
3
  end
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, :api_token, :throttle_limit,
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 api_token
31
- @api_token.to_s
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
@@ -15,4 +15,5 @@ Gem::Specification.new do |s|
15
15
  s.add_dependency "rack-attack"
16
16
  s.add_dependency "rails", ">= 8.1"
17
17
  s.metadata["rubygems_mfa_required"] = "true"
18
+ s.metadata["changelog_uri"] = "https://github.com/riggy/trackguard/blob/main/CHANGELOG.md"
18
19
  end
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.27.1
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