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.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class RuleSet
5
+ attr_reader :version, :rules, :caught_hosts
6
+
7
+ def initialize(rules = [], version = 0, caught_hosts = {})
8
+ @rules = rules.freeze
9
+ @version = version
10
+ @caught_hosts = caught_hosts.freeze
11
+ @blocklist_rules = rules.select { |rule| rule.kind == "blocklist" }.freeze
12
+ @safelist_rules = rules.select { |rule| rule.kind == "safelist" }.freeze
13
+ @throttle_rules = rules.select(&:throttle?).freeze
14
+ @honeypot_rules = rules.select(&:honeypot?).freeze
15
+ freeze
16
+ end
17
+
18
+ def self.empty
19
+ new([], 0, {})
20
+ end
21
+
22
+ attr_reader :blocklist_rules, :safelist_rules, :throttle_rules, :honeypot_rules
23
+
24
+ def blocklisted?(ip)
25
+ !blocklist_rule_for(ip).nil?
26
+ end
27
+
28
+ def safelisted?(ip)
29
+ !safelist_rule_for(ip).nil?
30
+ end
31
+
32
+ def blocklist_rule_for(ip)
33
+ @blocklist_rules.find { |rule| rule.matches_ip?(ip) }
34
+ end
35
+
36
+ def safelist_rule_for(ip)
37
+ @safelist_rules.find { |rule| rule.matches_ip?(ip) }
38
+ end
39
+
40
+ def honeypot_rule_for_path(path)
41
+ @honeypot_rules.find { |rule| rule.matches_path?(path) }
42
+ end
43
+
44
+ def caught?(ip)
45
+ !caught_host(ip).nil?
46
+ end
47
+
48
+ def caught_host(ip)
49
+ @caught_hosts[ip.to_s]
50
+ end
51
+
52
+ def empty?
53
+ @rules.empty? && @caught_hosts.empty?
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Parry
6
+ class RuleStore
7
+ def all
8
+ raw = Parry.with_redis { |redis| redis.hgetall(rules_key) }
9
+ raw.values.filter_map { |json| Rule.from_json(json) }.sort_by { |rule| [rule.created_at || 0, rule.id.to_s] }
10
+ end
11
+
12
+ def where(kind:)
13
+ all.select { |rule| rule.kind == kind.to_s }
14
+ end
15
+
16
+ def find(id)
17
+ json = Parry.with_redis { |redis| redis.hget(rules_key, id.to_s) }
18
+ json && Rule.from_json(json)
19
+ end
20
+
21
+ def count
22
+ Parry.with_redis { |redis| redis.hlen(rules_key) }
23
+ end
24
+
25
+ def save(rule)
26
+ rule.id ||= SecureRandom.hex(8)
27
+ rule.normalize!
28
+ return false unless rule.valid?
29
+
30
+ rule.created_at ||= Time.now.to_i
31
+ Parry.with_redis do |redis|
32
+ redis.hset(rules_key, rule.id, rule.to_json)
33
+ redis.incr(version_key)
34
+ end
35
+ rule.persisted = true
36
+ true
37
+ end
38
+
39
+ def delete(id)
40
+ deleted = Parry.with_redis do |redis|
41
+ count = redis.hdel(rules_key, id.to_s)
42
+ redis.incr(version_key) if count > 0
43
+ count
44
+ end
45
+ deleted > 0
46
+ end
47
+
48
+ def delete_matching_ip(ip, kinds: ["blocklist"])
49
+ kinds = Array(kinds).map(&:to_s)
50
+ matching = all.select { |rule| kinds.include?(rule.kind) && rule.matches_ip?(ip) }
51
+ return matching if matching.empty?
52
+
53
+ Parry.with_redis do |redis|
54
+ redis.hdel(rules_key, matching.map(&:id))
55
+ redis.incr(version_key)
56
+ end
57
+ matching
58
+ end
59
+
60
+ def version
61
+ Parry.with_redis { |redis| redis.get(version_key) }.to_i
62
+ end
63
+
64
+ def bump_version
65
+ Parry.with_redis { |redis| redis.incr(version_key) }
66
+ end
67
+
68
+ def rule_set
69
+ current = version
70
+ RuleSet.new(all, current, Parry.honeypots.to_h)
71
+ end
72
+
73
+ def clear
74
+ Parry.with_redis do |redis|
75
+ redis.del(rules_key)
76
+ redis.incr(version_key)
77
+ end
78
+ end
79
+
80
+ def rules_key
81
+ "#{Parry.config.key_prefix}:rules"
82
+ end
83
+
84
+ def version_key
85
+ "#{Parry.config.key_prefix}:rules_version"
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Parry
6
+ class RuleTransfer
7
+ FORMAT = 1
8
+ MAX_BYTES = 1_048_576
9
+
10
+ Result = Struct.new(:imported, :errors, :replaced, keyword_init: true) do
11
+ def ok?
12
+ errors.empty?
13
+ end
14
+
15
+ def replaced?
16
+ !!replaced
17
+ end
18
+ end
19
+
20
+ class << self
21
+ def export
22
+ {
23
+ "format" => FORMAT,
24
+ "parry_version" => Parry::VERSION,
25
+ "exported_at" => Time.now.to_i,
26
+ "rules" => Parry.store.all.map(&:to_h)
27
+ }
28
+ end
29
+
30
+ def export_json
31
+ JSON.pretty_generate(export)
32
+ end
33
+
34
+ def filename(at: Time.now)
35
+ "parry-rules-#{at.strftime("%Y%m%d-%H%M%S")}.json"
36
+ end
37
+
38
+ def import(payload, replace: false)
39
+ payload = payload.to_s
40
+ return failure("The file is empty.") if payload.strip.empty?
41
+ return failure("The file is bigger than #{MAX_BYTES / 1024}KB.") if payload.bytesize > MAX_BYTES
42
+
43
+ data = begin
44
+ JSON.parse(payload)
45
+ rescue JSON::ParserError => e
46
+ return failure("The file is not valid JSON (#{e.message.lines.first.to_s.strip}).")
47
+ end
48
+
49
+ entries = entries_in(data)
50
+ return failure(%(No rules found. Expected an export with a "rules" list.)) if entries.nil?
51
+ return failure("The file contains no rules.") if entries.empty?
52
+
53
+ apply(entries, replace: replace)
54
+ end
55
+
56
+ private
57
+
58
+ def entries_in(data)
59
+ case data
60
+ when Hash then data["rules"].is_a?(Array) ? data["rules"] : nil
61
+ when Array then data
62
+ end
63
+ end
64
+
65
+ def apply(entries, replace:)
66
+ snapshot = Parry.with_redis { |redis| redis.hgetall(Parry.store.rules_key) }
67
+ errors = []
68
+ imported = 0
69
+
70
+ Parry.store.clear if replace
71
+
72
+ entries.each_with_index do |attrs, index|
73
+ rule = build(attrs)
74
+
75
+ if rule.nil?
76
+ errors << "Rule #{index + 1} could not be read."
77
+ elsif Parry.store.save(rule)
78
+ imported += 1
79
+ else
80
+ errors << "Rule #{index + 1}#{label_for(rule)}: #{rule.errors.full_messages.to_sentence}."
81
+ end
82
+ end
83
+
84
+ if errors.any?
85
+ restore(snapshot)
86
+ return Result.new(imported: 0, errors: errors, replaced: false)
87
+ end
88
+
89
+ Result.new(imported: imported, errors: [], replaced: replace)
90
+ end
91
+
92
+ def build(attrs)
93
+ return nil unless attrs.is_a?(Hash)
94
+
95
+ rule = Rule.from_json(attrs.to_json)
96
+ rule&.persisted = false
97
+ rule
98
+ end
99
+
100
+ def label_for(rule)
101
+ label = rule.label
102
+ label.present? ? " (#{label})" : ""
103
+ end
104
+
105
+ def restore(snapshot)
106
+ Parry.with_redis do |redis|
107
+ redis.del(Parry.store.rules_key)
108
+ snapshot.each { |id, json| redis.hset(Parry.store.rules_key, id, json) }
109
+ redis.incr(Parry.store.version_key)
110
+ end
111
+ end
112
+
113
+ def failure(message)
114
+ Result.new(imported: 0, errors: [message], replaced: false)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class Tracker
5
+ def record(ip:, match_type:, rule: nil, path: nil, at: Time.now)
6
+ return if ip.nil? || ip.to_s.empty?
7
+
8
+ timestamp = at.to_i
9
+ key = host_key(ip)
10
+ Parry.with_redis do |redis|
11
+ redis.multi do |tx|
12
+ tx.hincrby(key, "hits", 1)
13
+ tx.hset(key, "ip", ip.to_s, "match_type", match_type.to_s, "last_seen", timestamp.to_s)
14
+ tx.hsetnx(key, "first_seen", timestamp.to_s)
15
+ tx.hset(key, "rule", rule.to_s) if rule
16
+ tx.hset(key, "path", path.to_s) if path
17
+ tx.zadd(index_key, timestamp, ip.to_s)
18
+ end
19
+ end
20
+ trim!
21
+ end
22
+
23
+ def all(limit: 100)
24
+ ips = Parry.with_redis { |redis| redis.zrevrange(index_key, 0, limit - 1) }
25
+ return [] if ips.empty?
26
+
27
+ hashes = Parry.with_redis do |redis|
28
+ redis.pipelined { |pipeline| ips.each { |ip| pipeline.hgetall(host_key(ip)) } }
29
+ end
30
+
31
+ ips.zip(hashes).filter_map { |ip, hash| BlockedHost.from_hash(ip, hash) }
32
+ end
33
+
34
+ def find(ip)
35
+ hash = Parry.with_redis { |redis| redis.hgetall(host_key(ip)) }
36
+ BlockedHost.from_hash(ip, hash)
37
+ end
38
+
39
+ def count
40
+ Parry.with_redis { |redis| redis.zcard(index_key) }
41
+ end
42
+
43
+ def forget(ip)
44
+ Parry.with_redis do |redis|
45
+ redis.multi do |tx|
46
+ tx.zrem(index_key, ip.to_s)
47
+ tx.del(host_key(ip))
48
+ end
49
+ end
50
+ true
51
+ end
52
+
53
+ def clear
54
+ Parry.with_redis do |redis|
55
+ ips = redis.zrange(index_key, 0, -1)
56
+ redis.del(*ips.map { |ip| host_key(ip) }) unless ips.empty?
57
+ redis.del(index_key)
58
+ end
59
+ end
60
+
61
+ def index_key
62
+ "#{Parry.config.key_prefix}:blocked_hosts"
63
+ end
64
+
65
+ def host_key(ip)
66
+ "#{Parry.config.key_prefix}:blocked_host:#{ip}"
67
+ end
68
+
69
+ private
70
+
71
+ def trim!
72
+ max = Parry.config.max_tracked_hosts
73
+ return if max.nil? || max <= 0
74
+
75
+ Parry.with_redis do |redis|
76
+ excess = redis.zcard(index_key) - max
77
+ next if excess <= 0
78
+
79
+ victims = redis.zrange(index_key, 0, excess - 1)
80
+ next if victims.empty?
81
+
82
+ redis.multi do |tx|
83
+ tx.zrem(index_key, victims)
84
+ tx.del(*victims.map { |ip| host_key(ip) })
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
data/lib/parry/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Parry
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/parry.rb CHANGED
@@ -1,8 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "logger"
4
+ require "active_support"
5
+ require "active_support/core_ext/object/blank"
6
+ require "active_support/core_ext/hash/keys"
7
+ require "active_support/core_ext/hash/slice"
8
+ require "rack/attack"
9
+
3
10
  require_relative "parry/version"
11
+ require_relative "parry/configuration"
12
+ require_relative "parry/rule"
13
+ require_relative "parry/rule_set"
14
+ require_relative "parry/rule_store"
15
+ require_relative "parry/rule_transfer"
16
+ require_relative "parry/blocked_host"
17
+ require_relative "parry/caught_host"
18
+ require_relative "parry/honeypot_store"
19
+ require_relative "parry/tracker"
20
+ require_relative "parry/rack_attack_adapter"
4
21
 
5
22
  module Parry
6
23
  class Error < StandardError; end
7
- # Your code goes here...
24
+
25
+ class << self
26
+ attr_writer :logger
27
+
28
+ def config
29
+ @config ||= Configuration.new
30
+ end
31
+ alias_method :configuration, :config
32
+
33
+ def configure
34
+ yield config
35
+ end
36
+
37
+ def with_redis(&block)
38
+ client = config.redis
39
+ client.respond_to?(:with) ? client.with(&block) : yield(client)
40
+ end
41
+
42
+ def store
43
+ @store ||= RuleStore.new
44
+ end
45
+
46
+ def tracker
47
+ @tracker ||= Tracker.new
48
+ end
49
+
50
+ def honeypots
51
+ @honeypots ||= HoneypotStore.new
52
+ end
53
+
54
+ def adapter
55
+ @adapter ||= RackAttackAdapter.new
56
+ end
57
+
58
+ def install!
59
+ adapter.install!
60
+ end
61
+
62
+ def rules
63
+ store.all
64
+ end
65
+
66
+ def unblock(ip)
67
+ adapter.unblock(ip)
68
+ end
69
+
70
+ def blocked_hosts(limit: 100)
71
+ tracker.all(limit: limit)
72
+ end
73
+
74
+ def logger
75
+ @logger ||= if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
76
+ ::Rails.logger
77
+ else
78
+ ::Logger.new($stderr)
79
+ end
80
+ end
81
+
82
+ def reset!
83
+ @store = nil
84
+ @tracker = nil
85
+ @honeypots = nil
86
+ adapter.reset!
87
+ end
88
+ end
8
89
  end
90
+
91
+ require_relative "parry/engine" if defined?(::Rails)
metadata CHANGED
@@ -1,15 +1,101 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: parry
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg Molnar
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
12
- description: Coming soon....
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionpack
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activemodel
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activesupport
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.1'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rack-attack
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '6.6'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '6.6'
82
+ - !ruby/object:Gem::Dependency
83
+ name: redis
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '4.2'
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '4.2'
96
+ description: Parry is a mountable Rails engine to manage Rack::Attack rules (blocklists,
97
+ safelists and throttles) at runtime, inspect blocked hosts and unblock them. Rules
98
+ are stored in Redis and applied without a redeploy.
13
99
  email:
14
100
  - molnargerg@gmail.com
15
101
  executables: []
@@ -21,7 +107,30 @@ files:
21
107
  - LICENSE.txt
22
108
  - README.md
23
109
  - Rakefile
110
+ - app/controllers/parry/application_controller.rb
111
+ - app/controllers/parry/blocked_hosts_controller.rb
112
+ - app/controllers/parry/rules_controller.rb
113
+ - app/helpers/parry/application_helper.rb
114
+ - app/views/layouts/parry/application.html.erb
115
+ - app/views/parry/blocked_hosts/index.html.erb
116
+ - app/views/parry/rules/_form.html.erb
117
+ - app/views/parry/rules/edit.html.erb
118
+ - app/views/parry/rules/import.html.erb
119
+ - app/views/parry/rules/index.html.erb
120
+ - app/views/parry/rules/new.html.erb
121
+ - config/routes.rb
24
122
  - lib/parry.rb
123
+ - lib/parry/blocked_host.rb
124
+ - lib/parry/caught_host.rb
125
+ - lib/parry/configuration.rb
126
+ - lib/parry/engine.rb
127
+ - lib/parry/honeypot_store.rb
128
+ - lib/parry/rack_attack_adapter.rb
129
+ - lib/parry/rule.rb
130
+ - lib/parry/rule_set.rb
131
+ - lib/parry/rule_store.rb
132
+ - lib/parry/rule_transfer.rb
133
+ - lib/parry/tracker.rb
25
134
  - lib/parry/version.rb
26
135
  - sig/parry.rbs
27
136
  homepage: https://github.com/gregmolnar/parry
@@ -46,5 +155,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
155
  requirements: []
47
156
  rubygems_version: 4.0.3
48
157
  specification_version: 4
49
- summary: Coming soon....
158
+ summary: A Rails engine that provides a GUI for Rack::Attack
50
159
  test_files: []