parry 0.1.0 → 0.2.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.
data/config/routes.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ Parry::Engine.routes.draw do
4
+ resources :rules, except: [:show] do
5
+ collection do
6
+ get :export
7
+ get :import
8
+ post :import, action: :perform_import, as: :perform_import
9
+ end
10
+ end
11
+
12
+ resources :blocked_hosts, only: [:index] do
13
+ collection do
14
+ post :unblock
15
+ end
16
+ end
17
+
18
+ root to: "rules#index"
19
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class BlockedHost
5
+ attr_reader :ip, :hits, :rule, :match_type, :path, :first_seen, :last_seen
6
+
7
+ def self.from_hash(ip, hash)
8
+ return nil if hash.nil? || hash.empty?
9
+
10
+ new(
11
+ ip: ip,
12
+ hits: hash["hits"].to_i,
13
+ rule: hash["rule"],
14
+ match_type: hash["match_type"],
15
+ path: hash["path"],
16
+ first_seen: hash["first_seen"],
17
+ last_seen: hash["last_seen"]
18
+ )
19
+ end
20
+
21
+ def initialize(ip:, hits: 0, rule: nil, match_type: nil, path: nil, first_seen: nil, last_seen: nil)
22
+ @ip = ip
23
+ @hits = hits.to_i
24
+ @rule = rule
25
+ @match_type = match_type
26
+ @path = path
27
+ @first_seen = to_time(first_seen)
28
+ @last_seen = to_time(last_seen)
29
+ end
30
+
31
+ def throttled?
32
+ match_type.to_s == "throttle"
33
+ end
34
+
35
+ def blocklisted?
36
+ match_type.to_s == "blocklist"
37
+ end
38
+
39
+ def caught?
40
+ match_type.to_s == "honeypot"
41
+ end
42
+
43
+ def to_param
44
+ ip
45
+ end
46
+
47
+ private
48
+
49
+ def to_time(value)
50
+ Time.at(value.to_i) if value
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Parry
6
+ class CaughtHost
7
+ attr_reader :ip, :rule_id, :rule, :path, :caught_at
8
+
9
+ def self.from_json(ip, json)
10
+ data = JSON.parse(json)
11
+ new(ip: ip, rule_id: data["rule_id"], rule: data["rule"], path: data["path"],
12
+ caught_at: data["caught_at"])
13
+ rescue JSON::ParserError
14
+ nil
15
+ end
16
+
17
+ def initialize(ip:, rule_id: nil, rule: nil, path: nil, caught_at: nil)
18
+ @ip = ip.to_s
19
+ @rule_id = rule_id
20
+ @rule = rule
21
+ @path = path
22
+ @caught_at = caught_at.nil? ? nil : Time.at(caught_at.to_i)
23
+ end
24
+
25
+ def to_h
26
+ {rule_id: rule_id, rule: rule, path: path, caught_at: caught_at&.to_i}
27
+ end
28
+
29
+ def to_json(*args)
30
+ to_h.to_json(*args)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "redis"
4
+
5
+ module Parry
6
+ # Runtime configuration for the engine.
7
+ #
8
+ # Parry.configure do |config|
9
+ # config.redis = Redis.new(url: ENV["PARRY_REDIS_URL"])
10
+ # config.max_tracked_hosts = 5_000
11
+ # end
12
+ class Configuration
13
+ attr_writer :redis
14
+
15
+ # URL used to build the default Redis client. Ignored when +redis+ is set.
16
+ attr_accessor :redis_url
17
+
18
+ # Prefix for every Redis key Parry writes.
19
+ attr_accessor :key_prefix
20
+
21
+ # Register the Rack::Attack hooks automatically when the engine boots.
22
+ attr_accessor :auto_install
23
+
24
+ # Record blocked/throttled requests so they show up under "Blocked hosts".
25
+ attr_accessor :track_events
26
+
27
+ # Maximum number of hosts kept in the blocked hosts list.
28
+ attr_accessor :max_tracked_hosts
29
+
30
+ # How long a honeypot regex may spend on one path before it is abandoned.
31
+ # Guards against a pattern that backtracks catastrophically.
32
+ attr_accessor :regex_timeout
33
+
34
+ def initialize
35
+ @redis_url = ENV["PARRY_REDIS_URL"] || ENV["REDIS_URL"]
36
+ @key_prefix = "parry"
37
+ @auto_install = true
38
+ @track_events = true
39
+ @max_tracked_hosts = 1000
40
+ @regex_timeout = 0.05
41
+ end
42
+
43
+ # A Redis client, or anything responding to +with+ (such as a
44
+ # ConnectionPool) that yields one.
45
+ def redis
46
+ @redis ||= redis_url ? Redis.new(url: redis_url) : Redis.new
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Parry
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Parry
8
+
9
+ config.parry = Parry.config
10
+
11
+ # Runs after the host application's initializers so a Rack::Attack cache
12
+ # store configured there is already in place.
13
+ initializer "parry.install", after: :load_config_initializers do
14
+ Parry.install! if Parry.config.auto_install
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class HoneypotStore
5
+ def all
6
+ raw = Parry.with_redis { |redis| redis.hgetall(caught_key) }
7
+ raw.filter_map { |ip, json| CaughtHost.from_json(ip, json) }
8
+ end
9
+
10
+ def to_h
11
+ all.each_with_object({}) { |host, index| index[host.ip] = host }
12
+ end
13
+
14
+ def find(ip)
15
+ json = Parry.with_redis { |redis| redis.hget(caught_key, ip.to_s) }
16
+ json && CaughtHost.from_json(ip.to_s, json)
17
+ end
18
+
19
+ def record(ip, rule: nil, path: nil, at: Time.now)
20
+ return false if ip.nil? || ip.to_s.empty?
21
+
22
+ host = CaughtHost.new(
23
+ ip: ip,
24
+ rule_id: rule&.id,
25
+ rule: rule&.label,
26
+ path: path,
27
+ caught_at: at.to_i
28
+ )
29
+
30
+ added = Parry.with_redis do |redis|
31
+ stored = redis.hsetnx(caught_key, host.ip, host.to_json)
32
+ redis.incr(version_key) if stored
33
+ stored
34
+ end
35
+ !!added
36
+ end
37
+
38
+ def release(ip)
39
+ removed = Parry.with_redis do |redis|
40
+ count = redis.hdel(caught_key, ip.to_s)
41
+ redis.incr(version_key) if count > 0
42
+ count
43
+ end
44
+ removed > 0
45
+ end
46
+
47
+ def caught?(ip)
48
+ Parry.with_redis { |redis| redis.hexists(caught_key, ip.to_s) }
49
+ end
50
+
51
+ def count
52
+ Parry.with_redis { |redis| redis.hlen(caught_key) }
53
+ end
54
+
55
+ def clear
56
+ Parry.with_redis do |redis|
57
+ redis.del(caught_key)
58
+ redis.incr(version_key)
59
+ end
60
+ end
61
+
62
+ def caught_key
63
+ "#{Parry.config.key_prefix}:caught"
64
+ end
65
+
66
+ private
67
+
68
+ def version_key
69
+ Parry.store.version_key
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/attack"
4
+
5
+ module Parry
6
+ # Bridges the rules stored in Redis into Rack::Attack.
7
+ #
8
+ # A safelist and a blocklist check are registered once; both consult the
9
+ # current snapshot of the rules. Throttles have to be registered as real
10
+ # Rack::Attack throttles, so the snapshot is refreshed whenever the version
11
+ # counter in Redis changes - that is what makes GUI edits visible to every
12
+ # process without a redeploy.
13
+ class RackAttackAdapter
14
+ SAFELIST_NAME = "parry/safelist"
15
+ BLOCKLIST_NAME = "parry/blocklist"
16
+ THROTTLE_PREFIX = "parry/throttle/"
17
+ THROTTLE_NAME_PATTERN = /\A#{Regexp.escape(THROTTLE_PREFIX)}(?<id>.+)\z/
18
+ # Set while a request is being checked, so the tracker can report which rule
19
+ # matched without looking it up again.
20
+ MATCHED_RULE_KEY = "parry.matched_rule"
21
+
22
+ def initialize
23
+ @mutex = Mutex.new
24
+ @rule_set = RuleSet.empty
25
+ @version = nil
26
+ @installed = false
27
+ @subscribed = false
28
+ end
29
+
30
+ def installed?
31
+ @installed
32
+ end
33
+
34
+ # The snapshot every request is checked against.
35
+ attr_reader :rule_set
36
+
37
+ # Registers the Rack::Attack hooks. Safe to call more than once.
38
+ def install!
39
+ return false if @installed
40
+
41
+ adapter = self
42
+
43
+ Rack::Attack.safelist(SAFELIST_NAME) do |request|
44
+ # Runs on every request, before any blocklist or throttle, which makes
45
+ # it the natural place to pick up rule changes.
46
+ adapter.sync!
47
+ adapter.rule_set.safelisted?(request.ip)
48
+ end
49
+
50
+ Rack::Attack.blocklist(BLOCKLIST_NAME) do |request|
51
+ adapter.blocked?(request)
52
+ end
53
+
54
+ subscribe!
55
+ @installed = true
56
+ end
57
+
58
+ # Refreshes the snapshot when the version counter changed. Redis problems
59
+ # must not take the application down, so the last known snapshot is kept.
60
+ def sync!(force: false)
61
+ current = Parry.store.version
62
+ return @rule_set if !force && @version == current
63
+
64
+ @mutex.synchronize do
65
+ return @rule_set if !force && @version == current
66
+
67
+ rule_set = Parry.store.rule_set
68
+ register_throttles(rule_set)
69
+ @rule_set = rule_set
70
+ @version = rule_set.version
71
+ end
72
+ @rule_set
73
+ rescue => e
74
+ Parry.logger.error("[parry] could not load rules: #{e.class}: #{e.message}")
75
+ @rule_set
76
+ end
77
+
78
+ # Decides whether the request is blocked, and springs any honeypot it walks
79
+ # into. Runs after the safelist check, so a safelisted host is never
80
+ # caught.
81
+ def blocked?(request)
82
+ ip = request.ip
83
+ snapshot = @rule_set
84
+
85
+ if (rule = snapshot.blocklist_rule_for(ip))
86
+ request.env[MATCHED_RULE_KEY] = "Blocklist: #{rule.label}"
87
+ return true
88
+ end
89
+
90
+ if (caught = snapshot.caught_host(ip))
91
+ request.env[MATCHED_RULE_KEY] = honeypot_label(caught.rule)
92
+ return true
93
+ end
94
+
95
+ if (rule = snapshot.honeypot_rule_for_path(request.path))
96
+ catch_host(ip, rule, request.path)
97
+ request.env[MATCHED_RULE_KEY] = honeypot_label(rule.label)
98
+ return true
99
+ end
100
+
101
+ false
102
+ end
103
+
104
+ # Removes the Parry blocklist rules covering +ip+, resets the throttle
105
+ # counters recorded against it and forgets it in the blocked hosts list.
106
+ def unblock(ip)
107
+ removed = Parry.store.delete_matching_ip(ip, kinds: ["blocklist"])
108
+ Parry.honeypots.release(ip)
109
+ reset_throttle_counters(ip)
110
+ Parry.tracker.forget(ip)
111
+ sync!(force: true)
112
+ removed
113
+ end
114
+
115
+ # Resets the current counter window of every registered throttle for +ip+,
116
+ # including throttles the host application defined itself.
117
+ def reset_throttle_counters(ip)
118
+ discriminator = normalize_discriminator(ip)
119
+ Rack::Attack.throttles.each do |name, throttle|
120
+ period = throttle.period
121
+ next if period.respond_to?(:call)
122
+
123
+ Rack::Attack.cache.reset_count("#{name}:#{discriminator}", period)
124
+ end
125
+ true
126
+ rescue => e
127
+ Parry.logger.warn("[parry] could not reset throttle counters for #{ip}: #{e.class}: #{e.message}")
128
+ false
129
+ end
130
+
131
+ # Records blocked and throttled requests so they show up in the GUI.
132
+ def subscribe!
133
+ return false if @subscribed || !Parry.config.track_events
134
+
135
+ adapter = self
136
+ ActiveSupport::Notifications.subscribe(/\.rack_attack\z/) do |name, _start, _finish, _id, payload|
137
+ adapter.handle_event(name.split(".").first, payload[:request])
138
+ end
139
+ @subscribed = true
140
+ end
141
+
142
+ def handle_event(match_type, request)
143
+ return unless %w[blocklist throttle].include?(match_type)
144
+ return if request.nil?
145
+
146
+ Parry.tracker.record(
147
+ ip: request.ip,
148
+ match_type: match_type,
149
+ rule: rule_label_for(match_type, request),
150
+ path: request.path
151
+ )
152
+ rescue => e
153
+ Parry.logger.warn("[parry] could not record blocked host: #{e.class}: #{e.message}")
154
+ end
155
+
156
+ # Forgets the registered hooks. Only useful in tests.
157
+ def reset!
158
+ @rule_set = RuleSet.empty
159
+ @version = nil
160
+ @installed = false
161
+ end
162
+
163
+ private
164
+
165
+ # Records the host, but a Redis failure must not let it through: the
166
+ # caller blocks the request either way.
167
+ def catch_host(ip, rule, path)
168
+ Parry.honeypots.record(ip, rule: rule, path: path)
169
+ rescue => e
170
+ Parry.logger.error("[parry] could not record caught host #{ip}: #{e.class}: #{e.message}")
171
+ false
172
+ end
173
+
174
+ def honeypot_label(label)
175
+ label.to_s.empty? ? "Honeypot" : "Honeypot: #{label}"
176
+ end
177
+
178
+ def rule_label_for(match_type, request)
179
+ known = request.env[MATCHED_RULE_KEY]
180
+ return known if known
181
+
182
+ matched = request.env["rack.attack.matched"]
183
+
184
+ case matched
185
+ when BLOCKLIST_NAME
186
+ rule = @rule_set.blocklist_rule_for(request.ip)
187
+ rule ? "Blocklist: #{rule.label}" : "Blocklist"
188
+ when THROTTLE_NAME_PATTERN
189
+ rule = @rule_set.throttle_rules.find { |candidate| candidate.id == $~[:id] }
190
+ rule ? "Throttle: #{rule.label}" : "Throttle"
191
+ else
192
+ matched.to_s
193
+ end
194
+ end
195
+
196
+ def register_throttles(rule_set)
197
+ Rack::Attack.throttles.delete_if { |name, _| name.start_with?(THROTTLE_PREFIX) }
198
+
199
+ rule_set.throttle_rules.each do |rule|
200
+ path_prefix = rule.path.to_s
201
+ Rack::Attack.throttle("#{THROTTLE_PREFIX}#{rule.id}", limit: rule.limit, period: rule.period) do |request|
202
+ request.ip if path_prefix.empty? || request.path.start_with?(path_prefix)
203
+ end
204
+ end
205
+ end
206
+
207
+ def normalize_discriminator(value)
208
+ normalizer = Rack::Attack.throttle_discriminator_normalizer
209
+ normalizer ? normalizer.call(value) : value
210
+ end
211
+ end
212
+ end
data/lib/parry/rule.rb ADDED
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "json"
5
+ require "securerandom"
6
+ require "active_model"
7
+
8
+ module Parry
9
+ class Rule
10
+ include ActiveModel::Model
11
+ include ActiveModel::Attributes
12
+
13
+ KINDS = %w[honeypot blocklist safelist throttle].freeze
14
+ IP_KINDS = %w[blocklist safelist].freeze
15
+ PATH_MATCHES = %w[prefix regex].freeze
16
+
17
+ attribute :id, :string
18
+ attribute :kind, :string, default: "honeypot"
19
+ attribute :value, :string
20
+ attribute :name, :string
21
+ attribute :limit, :integer
22
+ attribute :period, :integer
23
+ attribute :path, :string
24
+ attribute :path_match, :string, default: "prefix"
25
+ attribute :created_at, :integer
26
+
27
+ attr_accessor :persisted
28
+
29
+ validates :kind, inclusion: {in: KINDS, message: "is not a valid rule type"}
30
+ validates :value, presence: true, if: :ip_rule?
31
+ validate :value_is_an_ip_or_cidr, if: -> { ip_rule? && value.present? }
32
+ validates :name, presence: true, if: :throttle?
33
+ validates :limit, numericality: {only_integer: true, greater_than: 0}, if: :throttle?
34
+ validates :period, numericality: {only_integer: true, greater_than: 0}, if: :throttle?
35
+ validate :name_is_unique_among_throttles, if: -> { throttle? && name.present? }
36
+ validates :path, presence: true, if: :honeypot?
37
+ validates :path_match, inclusion: {in: PATH_MATCHES, message: "is not a valid way to match a path"},
38
+ if: :honeypot?
39
+ validate :path_is_absolute, if: -> { honeypot? && prefix_match? && path.present? }
40
+ validate :path_is_a_valid_regexp, if: -> { honeypot? && regex_match? && path.present? }
41
+
42
+ class << self
43
+ def from_json(json)
44
+ attrs = JSON.parse(json)
45
+ rule = new(attrs.slice(*attribute_names))
46
+ rule.persisted = true
47
+ rule
48
+ rescue JSON::ParserError
49
+ nil
50
+ end
51
+
52
+ def kinds
53
+ KINDS
54
+ end
55
+ end
56
+
57
+ def ip_rule?
58
+ IP_KINDS.include?(kind)
59
+ end
60
+
61
+ def throttle?
62
+ kind == "throttle"
63
+ end
64
+
65
+ def honeypot?
66
+ kind == "honeypot"
67
+ end
68
+
69
+ def regex_match?
70
+ honeypot? && path_match.to_s == "regex"
71
+ end
72
+
73
+ def prefix_match?
74
+ !regex_match?
75
+ end
76
+
77
+ def persisted?
78
+ !!persisted
79
+ end
80
+
81
+ def to_key
82
+ id ? [id] : nil
83
+ end
84
+
85
+ def to_param
86
+ id
87
+ end
88
+
89
+ def label
90
+ return name if name.present?
91
+ return value if value.present?
92
+
93
+ path.to_s
94
+ end
95
+
96
+ def created_at_time
97
+ Time.at(created_at) if created_at
98
+ end
99
+
100
+ def matches_ip?(ip)
101
+ range = ip_range
102
+ return false if range.nil? || ip.nil? || ip.to_s.empty?
103
+
104
+ range.include?(IPAddr.new(ip.to_s))
105
+ rescue IPAddr::Error
106
+ false
107
+ end
108
+
109
+ def matches_path?(candidate)
110
+ return false if path.to_s.empty? || candidate.nil?
111
+
112
+ if regex_match?
113
+ matcher = path_regexp
114
+ !matcher.nil? && matcher.match?(candidate.to_s)
115
+ else
116
+ candidate.to_s.start_with?(path)
117
+ end
118
+ rescue Regexp::TimeoutError
119
+ # A bad pattern shouldn't hurt the request.
120
+ Parry.logger.error("[parry] the regex of rule #{id} timed out matching #{candidate}")
121
+ false
122
+ end
123
+
124
+ def path_regexp
125
+ return @path_regexp if defined?(@path_regexp)
126
+
127
+ @path_regexp = begin
128
+ Regexp.new(path.to_s, timeout: Parry.config.regex_timeout) if regex_match? && path.present?
129
+ rescue RegexpError
130
+ nil
131
+ end
132
+ end
133
+
134
+ def ip_range
135
+ return @ip_range if defined?(@ip_range)
136
+
137
+ @ip_range = begin
138
+ IPAddr.new(value.to_s) if ip_rule? && value.present?
139
+ rescue IPAddr::Error
140
+ nil
141
+ end
142
+ end
143
+
144
+ def normalize!
145
+ self.value = value.strip if value.is_a?(String)
146
+ self.name = name.strip if name.is_a?(String)
147
+ self.path = path.strip.presence if path.is_a?(String)
148
+ self.path_match = "prefix" unless PATH_MATCHES.include?(path_match)
149
+
150
+ if throttle?
151
+ self.value = nil
152
+ self.path_match = "prefix"
153
+ elsif honeypot?
154
+ self.value = nil
155
+ self.limit = nil
156
+ self.period = nil
157
+ else
158
+ self.limit = nil
159
+ self.period = nil
160
+ self.path = nil
161
+ self.path_match = "prefix"
162
+ end
163
+
164
+ remove_instance_variable(:@ip_range) if defined?(@ip_range)
165
+ remove_instance_variable(:@path_regexp) if defined?(@path_regexp)
166
+ self
167
+ end
168
+
169
+ def to_h
170
+ attributes.symbolize_keys
171
+ end
172
+
173
+ def to_json(*args)
174
+ to_h.to_json(*args)
175
+ end
176
+
177
+ private
178
+
179
+ def value_is_an_ip_or_cidr
180
+ IPAddr.new(value.to_s)
181
+ rescue IPAddr::Error
182
+ errors.add(:value, "is not a valid IP address or CIDR range")
183
+ end
184
+
185
+ def path_is_absolute
186
+ errors.add(:path, "has to start with /") unless path.to_s.start_with?("/")
187
+ end
188
+
189
+ def path_is_a_valid_regexp
190
+ Regexp.new(path.to_s)
191
+ rescue RegexpError => e
192
+ errors.add(:path, "is not a valid regular expression (#{e.message.lines.first.to_s.strip})")
193
+ end
194
+
195
+ def name_is_unique_among_throttles
196
+ taken = Parry.store.where(kind: "throttle").any? { |other| other.name == name && other.id != id }
197
+ errors.add(:name, "is already used by another throttle") if taken
198
+ rescue => e
199
+ Parry.logger.warn("[parry] could not verify throttle name uniqueness: #{e.class}: #{e.message}")
200
+ end
201
+ end
202
+ end