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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -2
- data/README.md +320 -18
- data/Rakefile +3 -1
- data/app/controllers/parry/application_controller.rb +8 -0
- data/app/controllers/parry/blocked_hosts_controller.rb +85 -0
- data/app/controllers/parry/rules_controller.rb +109 -0
- data/app/helpers/parry/application_helper.rb +21 -0
- data/app/views/layouts/parry/application.html.erb +341 -0
- data/app/views/parry/blocked_hosts/index.html.erb +88 -0
- data/app/views/parry/rules/_form.html.erb +101 -0
- data/app/views/parry/rules/edit.html.erb +10 -0
- data/app/views/parry/rules/import.html.erb +56 -0
- data/app/views/parry/rules/index.html.erb +80 -0
- data/app/views/parry/rules/new.html.erb +10 -0
- data/config/routes.rb +19 -0
- data/lib/parry/blocked_host.rb +53 -0
- data/lib/parry/caught_host.rb +33 -0
- data/lib/parry/configuration.rb +49 -0
- data/lib/parry/engine.rb +17 -0
- data/lib/parry/honeypot_store.rb +72 -0
- data/lib/parry/rack_attack_adapter.rb +212 -0
- data/lib/parry/rule.rb +202 -0
- data/lib/parry/rule_set.rb +56 -0
- data/lib/parry/rule_store.rb +88 -0
- data/lib/parry/rule_transfer.rb +118 -0
- data/lib/parry/tracker.rb +89 -0
- data/lib/parry/version.rb +1 -1
- data/lib/parry.rb +84 -1
- metadata +113 -4
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
|
data/lib/parry/engine.rb
ADDED
|
@@ -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
|