trackguard 0.27.0 → 0.28.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 (28) 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 +42 -0
  4. data/app/controllers/concerns/trackguard/page_tracker.rb +1 -1
  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/hub_helper.rb +11 -0
  10. data/app/jobs/trackguard/detect_suspicious_visitors_job.rb +64 -10
  11. data/app/jobs/trackguard/track_page_view_job.rb +4 -23
  12. data/app/models/trackguard/page_view.rb +3 -0
  13. data/app/models/trackguard/visitor.rb +12 -4
  14. data/app/services/trackguard/track_page_view.rb +35 -0
  15. data/app/views/trackguard/admin/_visit_row.html.erb +34 -7
  16. data/lib/generators/trackguard/install_generator.rb +4 -0
  17. data/lib/generators/trackguard/templates/add_suspicious_state_to_trackguard_visitors.rb +12 -0
  18. data/lib/generators/trackguard/templates/add_tracking_layer_to_trackguard_visits.rb +5 -0
  19. data/lib/generators/trackguard/templates/create_trackguard_visits.rb +1 -0
  20. data/lib/tasks/trackguard.rake +17 -53
  21. data/lib/trackguard/adapters/base.rb +5 -4
  22. data/lib/trackguard/adapters/hub.rb +85 -0
  23. data/lib/trackguard/adapters/local.rb +3 -3
  24. data/lib/trackguard/engine.rb +3 -4
  25. data/lib/trackguard/version.rb +1 -1
  26. data/lib/trackguard.rb +10 -4
  27. data/trackguard.gemspec +1 -0
  28. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 316a34bc16b1eb01f85bf67eb0590083bb785e3fc7ec33c155c88f710598bff8
4
- data.tar.gz: 274cb339c6c5b66bb6a806eadf2c85c2b6e17a571e5a6afd7b0add98ab318b2f
3
+ metadata.gz: c2629b3c673c2ac2c1f4e338dedc3abc7f9b572fb57e9b4f9091139809e362bc
4
+ data.tar.gz: 6b4657343eecf9de2b48bea0df3f24c4463109dc6ae521dc83dad79741bbfe99
5
5
  SHA512:
6
- metadata.gz: 44e7c8a7af2af602c82b0deccee434b1f648669436191cd2d912445deaf4d5b1b8164e2129cb70b88fe60d626034825823decb3a020148b25a954a76a53e2158
7
- data.tar.gz: 808ada400efdf41eb5861ef9218f8cef8e90032ab8cda97b38b9492347d1ab96b4f558f1079c20767e711369e2c28ae62fe91028022cb36f2484efd9ca9c524a
6
+ metadata.gz: d8b5fc84be6801c276f5d7cb7dd0007a7b1c7d5d19d5db9858f2412396e3c99fc604e9276cc34ee0a05b4b63cc01f72fac8e176b1c2e852405813e0e484b4733
7
+ data.tar.gz: 96c791bce0d1cd72230e89e34a6335e4c7dfdbe818f90dccbd466bb3e908c2b9437e820dfe6afb9ced8a605e5293f341c65dd541e603a8bd9594210009c4dfe8
@@ -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
  }
@@ -291,6 +291,10 @@
291
291
  background: rgba(239, 68, 68, 0.05);
292
292
  }
293
293
 
294
+ .tg-row--suspicious {
295
+ background: rgba(251, 191, 36, 0.1);
296
+ }
297
+
294
298
  .tg-row--whitelisted {
295
299
  background: rgba(34, 197, 94, 0.05);
296
300
  }
@@ -352,6 +356,14 @@
352
356
  font-size: 0.875rem;
353
357
  }
354
358
 
359
+ .tg-summary__layer {
360
+ width: 2.5rem;
361
+ text-align: center;
362
+ flex-shrink: 0;
363
+ color: #d1d5db;
364
+ font-size: 0.875rem;
365
+ }
366
+
355
367
  .tg-summary__time {
356
368
  font-size: 0.8125rem;
357
369
  color: #9ca3af;
@@ -366,6 +378,13 @@
366
378
  vertical-align: middle;
367
379
  }
368
380
 
381
+ .tg-suspicious-icon {
382
+ width: 1rem;
383
+ height: 1rem;
384
+ color: #f59e0b;
385
+ vertical-align: middle;
386
+ }
387
+
369
388
  .tg-whitelist-icon {
370
389
  width: 1rem;
371
390
  height: 1rem;
@@ -373,6 +392,27 @@
373
392
  vertical-align: middle;
374
393
  }
375
394
 
395
+ .tg-layer-badge {
396
+ display: inline-block;
397
+ font-size: 0.625rem;
398
+ font-weight: 600;
399
+ letter-spacing: 0.04em;
400
+ text-transform: uppercase;
401
+ border-radius: 3px;
402
+ padding: 0.125rem 0.3rem;
403
+ line-height: 1.4;
404
+ }
405
+
406
+ .tg-layer-badge--js {
407
+ background: #d1fae5;
408
+ color: #065f46;
409
+ }
410
+
411
+ .tg-layer-badge--backend {
412
+ background: #ffedd5;
413
+ color: #c2410c;
414
+ }
415
+
376
416
  /* ── Detail panel ──────────────────────────────────────────────────── */
377
417
  .tg-detail {
378
418
  padding: 0 1rem 1rem 1rem;
@@ -428,6 +468,8 @@
428
468
 
429
469
  .tg-dl__def--flagged { color: #dc2626; }
430
470
 
471
+ .tg-dl__def--suspicious { color: #d97706; }
472
+
431
473
  .tg-dl__def--muted { color: #9ca3af; }
432
474
 
433
475
  .tg-dl__def--whitelisted { color: #059669; }
@@ -30,7 +30,7 @@ module Trackguard
30
30
  session_id: session.id.to_s,
31
31
  trace_id: @trace_id,
32
32
  source: extract_source,
33
- initial: false,
33
+ tracking_layer: "backend",
34
34
  http_method: request.request_method
35
35
  )
36
36
  end
@@ -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
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trackguard
4
+ module HubHelper
5
+ def trackguard_hub_js_tag
6
+ return unless Trackguard.adapter.is_a?(Trackguard::Adapters::Hub)
7
+
8
+ tag.script(src: "https://app.trackguard.dev/track.js", data: { api_key: Trackguard.hub_api_key })
9
+ end
10
+ end
11
+ end
@@ -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)
@@ -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,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&.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>
@@ -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
@@ -72,41 +72,26 @@ namespace :trackguard do
72
72
  next
73
73
  end
74
74
 
75
- base_version = File.basename(monolithic).match(/\A(\d{14})/)[1].to_i
76
- monolithic_content = File.read(monolithic)
77
- template_dir = Trackguard::Engine.root.join("lib", "generators", "trackguard", "templates")
78
- conn = ActiveRecord::Base.connection
75
+ base_version = File.basename(monolithic).match(/\A(\d{14})/)[1].to_i
76
+ template_dir = Trackguard::Engine.root.join("lib", "generators", "trackguard", "templates")
79
77
 
80
78
  splits = [
81
- [ base_version, "create_trackguard_visitors", "trackguard_visitors" ],
82
- [ base_version + 1, "create_trackguard_visits", "trackguard_visits" ],
83
- [ base_version + 2, "create_trackguard_whitelisted_ips", "trackguard_whitelisted_ips" ],
84
- [ base_version + 3, "create_trackguard_blocked_user_agents", "trackguard_blocked_user_agents" ],
85
- [ base_version + 4, "create_trackguard_blocked_paths", "trackguard_blocked_paths" ]
79
+ [ base_version, "create_trackguard_visitors" ],
80
+ [ base_version + 1, "create_trackguard_visits" ],
81
+ [ base_version + 2, "create_trackguard_whitelisted_ips" ],
82
+ [ base_version + 3, "create_trackguard_blocked_user_agents" ],
83
+ [ base_version + 4, "create_trackguard_blocked_paths" ]
86
84
  ]
87
85
 
88
- # Only inject entries for tables the monolithic actually created.
89
- # Tables absent from it are left for `rails generate trackguard:install` + `db:migrate`.
90
- to_inject, to_defer = splits.partition { |_, _, table| monolithic_content.include?("create_table :#{table}") }
91
-
92
- new_versions = to_inject.map { |ts, _, _| ts }.reject { |ts| ts == base_version }
93
- create_list = to_inject.map { |ts, name, _| " db/migrate/#{ts}_#{name}.rb" }.join("\n")
86
+ create_list = splits.map { |ts, name| " db/migrate/#{ts}_#{name}.rb" }.join("\n")
94
87
 
95
88
  puts "This will make the following changes to your application:"
96
89
  puts ""
97
90
  puts " Remove: db/migrate/#{File.basename(monolithic)}"
98
- puts " Create:"
91
+ puts " Create (table/index creation guarded with if_not_exists):"
99
92
  puts create_list
100
93
  puts ""
101
- puts " Update schema_migrations: add versions #{new_versions.join(', ')}" if new_versions.any?
102
- puts " Update db/schema.rb if its version points at #{base_version}"
103
- if to_defer.any?
104
- puts ""
105
- puts " Not in monolithic — deferred to install generator + db:migrate:"
106
- to_defer.each { |_, name, _| puts " #{name}" }
107
- end
108
- puts ""
109
- puts " Tables themselves are NOT touched. Bookkeeping only."
94
+ puts " Run `rails db:migrate` afterwards existing tables and indexes are skipped safely."
110
95
  puts ""
111
96
 
112
97
  $stdout.print "Proceed? [y/N] "
@@ -118,44 +103,23 @@ namespace :trackguard do
118
103
 
119
104
  puts ""
120
105
 
121
- sm_insert = lambda do |v|
122
- quoted = conn.quote(v.to_s)
123
- unless conn.select_value("SELECT 1 FROM schema_migrations WHERE version = #{quoted}")
124
- conn.execute("INSERT INTO schema_migrations (version) VALUES (#{quoted})")
125
- end
126
- end
127
-
128
- last_injected = base_version
129
-
130
- to_inject.each do |ts, name, _|
106
+ splits.each do |ts, name|
131
107
  path = migrate_dir.join("#{ts}_#{name}.rb")
132
108
  if path.exist?
133
109
  puts " skip #{path.basename}"
134
110
  else
135
- content = ERB.new(File.read(template_dir.join("#{name}.rb"))).result(binding)
136
- path.write(content)
111
+ raw = ERB.new(File.read(template_dir.join("#{name}.rb"))).result(binding)
112
+ guarded = raw
113
+ .gsub(/create_table (\S+) do/, 'create_table \1, if_not_exists: true do')
114
+ .gsub(/^(\s+add_index .+)$/, '\1, if_not_exists: true')
115
+ path.write(guarded)
137
116
  puts " create #{path.basename}"
138
117
  end
139
- sm_insert.call(ts)
140
- last_injected = ts
141
118
  end
142
119
 
143
120
  FileUtils.rm(monolithic)
144
121
  puts " remove #{File.basename(monolithic)}"
145
- # base_version stays in schema_migrations — it now belongs to create_trackguard_visitors
146
-
147
- schema_path = Rails.root.join("db", "schema.rb")
148
- if schema_path.exist? && last_injected != base_version
149
- schema_content = schema_path.read
150
- if schema_content.include?("version: #{base_version}")
151
- schema_path.write(schema_content.sub("version: #{base_version}", "version: #{last_injected}"))
152
- puts " update db/schema.rb: version #{base_version} → #{last_injected}"
153
- end
154
- end
155
122
 
156
- if to_defer.any?
157
- puts "\nRun `rails generate trackguard:install && rails db:migrate` to create the remaining tables."
158
- end
159
- puts "\nDone. Run `rails db:migrate:status` to verify."
123
+ puts "\nDone. Run `rails db:migrate` to apply any missing migrations."
160
124
  end
161
125
  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:)
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
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:)
31
32
  raise NotImplementedError, "#{self.class}#perform_track_page_view"
32
33
  end
33
34
  end
@@ -0,0 +1,85 @@
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
+ # placeholder: hub adapter does not persist blocked requests locally
35
+ end
36
+
37
+ protected
38
+
39
+ def perform_track_page_view(path:, ip:, user_agent:, referer:, session_id:, trace_id:, source:,
40
+ tracking_layer:, http_method:)
41
+ # placeholder: hub tracking not yet implemented
42
+ end
43
+
44
+ private
45
+
46
+ def rules
47
+ Rails.cache.fetch(CACHE_KEY, expires_in: Trackguard.hub_rules_ttl, race_condition_ttl: 10) do
48
+ fresh = fetch_rules_from_hub
49
+ Rails.cache.write(STALE_KEY, fresh, expires_in: 24.hours)
50
+ fresh
51
+ end
52
+ rescue StandardError => e
53
+ Rails.logger.warn("[Trackguard::Adapters::Hub] Failed to fetch rules: #{e.message}")
54
+ Rails.cache.read(STALE_KEY) || {}
55
+ end
56
+
57
+ def fetch_rules_from_hub
58
+ uri = URI("#{Trackguard.hub_url}/api/rules")
59
+ request = Net::HTTP::Get.new(uri)
60
+ request["Authorization"] = "Bearer #{Trackguard.hub_secret_key}"
61
+ request["Accept"] = "application/json"
62
+
63
+ etag = Rails.cache.read(ETAG_KEY)
64
+ request["If-None-Match"] = etag if etag
65
+
66
+ response = Net::HTTP.start(
67
+ uri.hostname, uri.port,
68
+ use_ssl: uri.scheme == "https",
69
+ open_timeout: 3,
70
+ read_timeout: 5
71
+ ) do |http|
72
+ http.request(request)
73
+ end
74
+
75
+ return Rails.cache.read(STALE_KEY) || {} if response.is_a?(Net::HTTPNotModified)
76
+
77
+ raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
78
+
79
+ fresh = JSON.parse(response.body, symbolize_names: true)
80
+ Rails.cache.write(ETAG_KEY, response["ETag"], expires_in: 24.hours) if response["ETag"]
81
+ fresh
82
+ end
83
+ end
84
+ end
85
+ 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:)
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,9 @@ 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
9
+ ActionController::Base.helper Trackguard::HubHelper
11
10
  end
12
11
 
13
12
  initializer "trackguard.assets" do |app|
@@ -1,3 +1,3 @@
1
1
  module Trackguard
2
- VERSION = "0.27.0".freeze
2
+ VERSION = "0.28.0".freeze
3
3
  end
data/lib/trackguard.rb CHANGED
@@ -5,11 +5,13 @@ require "trackguard/engine"
5
5
  require "trackguard/rack_attack"
6
6
  require "trackguard/adapters/base"
7
7
  require "trackguard/adapters/local"
8
+ require "trackguard/adapters/hub"
8
9
 
9
10
  module Trackguard
10
11
  class << self
11
- attr_writer :authenticate_admin_with, :admin_layout, :admin_path, :back_label, :api_token, :throttle_limit,
12
- :throttle_period
12
+ attr_writer :authenticate_admin_with, :admin_layout, :admin_path, :back_label, :local_api_token, :throttle_limit,
13
+ :throttle_period, :hub_secret_key, :hub_api_key, :hub_rules_ttl
14
+ attr_accessor :hub_url
13
15
 
14
16
  def authenticate_admin_with
15
17
  @authenticate_admin_with ||= proc {}
@@ -27,8 +29,8 @@ module Trackguard
27
29
  @back_label ||= "Back to app"
28
30
  end
29
31
 
30
- def api_token
31
- @api_token.to_s
32
+ def local_api_token
33
+ @local_api_token.to_s
32
34
  end
33
35
 
34
36
  def throttle_limit
@@ -39,6 +41,10 @@ module Trackguard
39
41
  @throttle_period ||= 60
40
42
  end
41
43
 
44
+ def hub_secret_key = @hub_secret_key.to_s
45
+ def hub_api_key = @hub_api_key.to_s
46
+ def hub_rules_ttl = @hub_rules_ttl ||= 5.minutes
47
+
42
48
  def adapter
43
49
  @adapter ||= Trackguard::Adapters::Local.new
44
50
  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.0
4
+ version: 0.28.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Krzysztof Rygielski
@@ -58,6 +58,7 @@ files:
58
58
  - app/controllers/trackguard/admin/whitelisted_ips_controller.rb
59
59
  - app/controllers/trackguard/page_views_controller.rb
60
60
  - app/helpers/trackguard/application_helper.rb
61
+ - app/helpers/trackguard/hub_helper.rb
61
62
  - app/jobs/trackguard/detect_suspicious_visitors_job.rb
62
63
  - app/jobs/trackguard/track_blocked_request_job.rb
63
64
  - app/jobs/trackguard/track_page_view_job.rb
@@ -70,6 +71,7 @@ files:
70
71
  - app/models/trackguard/whitelisted_ip.rb
71
72
  - app/services/trackguard/analytics_query.rb
72
73
  - app/services/trackguard/application_service.rb
74
+ - app/services/trackguard/track_page_view.rb
73
75
  - app/views/layouts/trackguard/admin.html.erb
74
76
  - app/views/trackguard/admin/_nav.html.erb
75
77
  - app/views/trackguard/admin/_stats_panel.html.erb
@@ -82,6 +84,8 @@ files:
82
84
  - config/importmap.rb
83
85
  - config/routes.rb
84
86
  - lib/generators/trackguard/install_generator.rb
87
+ - lib/generators/trackguard/templates/add_suspicious_state_to_trackguard_visitors.rb
88
+ - lib/generators/trackguard/templates/add_tracking_layer_to_trackguard_visits.rb
85
89
  - lib/generators/trackguard/templates/create_trackguard_blocked_paths.rb
86
90
  - lib/generators/trackguard/templates/create_trackguard_blocked_user_agents.rb
87
91
  - lib/generators/trackguard/templates/create_trackguard_visitors.rb
@@ -90,6 +94,7 @@ files:
90
94
  - lib/tasks/trackguard.rake
91
95
  - lib/trackguard.rb
92
96
  - lib/trackguard/adapters/base.rb
97
+ - lib/trackguard/adapters/hub.rb
93
98
  - lib/trackguard/adapters/local.rb
94
99
  - lib/trackguard/engine.rb
95
100
  - lib/trackguard/rack_attack.rb
@@ -100,6 +105,7 @@ licenses:
100
105
  - MIT
101
106
  metadata:
102
107
  rubygems_mfa_required: 'true'
108
+ changelog_uri: https://github.com/riggy/trackguard/blob/main/CHANGELOG.md
103
109
  rdoc_options: []
104
110
  require_paths:
105
111
  - lib