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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8cc12161a95a47d2f1c7fbc380e3ce403ed222c52db5aaadfaa888727018434d
4
- data.tar.gz: 167cfa0213772b36b2b120a48b12940bfb6ab5a7489dc9a88ee34399cee181ea
3
+ metadata.gz: e471289a3e66fdd541918f0a5891c24062af5609ca7fd958d75b4927660ba679
4
+ data.tar.gz: e9545bd991b262a57de839c253cc594ecca37ad886fd9a2fcc2e35b5aa0ee5d7
5
5
  SHA512:
6
- metadata.gz: 4712d4f7e07147d8c42055b3c90876a2138b10a51c5625f4e36a5bfc4aadb917371b83ddd34b04fea4e1c011159900532851c1dcf3939d829cadc657fb867846
7
- data.tar.gz: 4be6faf2aeacf909ee4ba20603f7e7b18660e23aa0d39dffe0c1bf564f412ac0c0fdc551115bc17b24db8a44fac3facec7379d12dc1a51d32b922efb6216254d
6
+ metadata.gz: 135b9c7801d8e82bdced96eb1a578f5e60fc7ee256aaac6f6eb5dfe2a4274c3419a4073fa6585ff5342a224041cd8db339653c7321ea715ca526c0f018326aa6
7
+ data.tar.gz: 194b02b4a325cbdc86f2cc536a0d09359e2ced883787625992b7d49a15fd10b4a78a71fa782510e386ed85d63fd9fb7f82102c804a919cd5e70a1b74d7d81525
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2026-07-13
3
+ ## [0.2.0] - 2026-10-16
4
4
 
5
- - Initial release
5
+ - Paginate the blocked hosts page, 50 at a time.
6
+
7
+ - Export and import rules as JSON, from the GUI or through `Parry::RuleTransfer`.
8
+
9
+ ## [0.1.0] - 2026-08-20
10
+
11
+ - Initial release: a mountable Rails engine with a GUI for Rack::Attack.
12
+ Manage honeypot, blocklist, safelist and throttle rules stored in Redis, see the hosts that were blocked,
13
+ throttled or caught, and unblock them. Honeypots match their path by prefix or by regular expression.
data/README.md CHANGED
@@ -1,43 +1,345 @@
1
1
  # Parry
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ Parry is a mountable Rails engine that puts a GUI in front of [Rack::Attack](https://github.com/rack/rack-attack).
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/parry`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ Rack::Attack rules normally live in an initializer, so changing them means a deploy. Parry keeps its rules in
6
+ Redis instead and applies them to every application process as soon as you save them:
7
+
8
+ - manage **blocklists**, **safelists**, **throttles** and **honeypots** from a web page
9
+ - see the hosts that were **blocked or throttled**, with hit counts and the rule they matched
10
+ - **unblock** a host: the blocklist rules covering it are deleted and its throttle counters are reset
11
+
12
+ Parry has no opinion about authentication - mount it behind whatever your application already uses.
6
13
 
7
14
  ## Installation
8
15
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
16
+ ```ruby
17
+ # Gemfile
18
+ gem "parry"
19
+ ```
20
+
21
+ Parry depends on `rack-attack` and `redis`. Make sure Rack::Attack itself is set up in your application:
22
+
23
+ ```ruby
24
+ # config/application.rb
25
+ config.middleware.use Rack::Attack
26
+ ```
27
+
28
+ ```ruby
29
+ # config/initializers/rack_attack.rb
30
+ # Rack::Attack needs a shared, incrementable cache store. Redis is a good fit.
31
+ Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
32
+ ```
33
+
34
+ Then mount the engine:
35
+
36
+ ```ruby
37
+ # config/routes.rb
38
+ mount Parry::Engine => "/parry"
39
+ ```
40
+
41
+ ## Authentication
42
+
43
+ The engine is deliberately unauthenticated, so restrict it when you mount it. With Devise:
44
+
45
+ ```ruby
46
+ authenticate :user, ->(user) { user.admin? } do
47
+ mount Parry::Engine => "/parry"
48
+ end
49
+ ```
50
+
51
+ With HTTP basic auth:
52
+
53
+ ```ruby
54
+ Parry::Engine.middleware.use(Rack::Auth::Basic) do |username, password|
55
+ ActiveSupport::SecurityUtils.secure_compare(username, ENV.fetch("PARRY_USER")) &
56
+ ActiveSupport::SecurityUtils.secure_compare(password, ENV.fetch("PARRY_PASSWORD"))
57
+ end
58
+
59
+ mount Parry::Engine => "/parry"
60
+ ```
61
+
62
+ Or with a route constraint:
63
+
64
+ ```ruby
65
+ constraints ->(request) { request.session[:admin] } do
66
+ mount Parry::Engine => "/parry"
67
+ end
68
+ ```
69
+
70
+ ## Configuration
71
+
72
+ ```ruby
73
+ # config/initializers/parry.rb
74
+ Parry.configure do |config|
75
+ # Redis client. Defaults to Redis.new(url: ENV["PARRY_REDIS_URL"] || ENV["REDIS_URL"]).
76
+ # A ConnectionPool works too - anything that responds to #with.
77
+ config.redis = Redis.new(url: ENV["PARRY_REDIS_URL"])
78
+
79
+ # Prefix for every key Parry writes. Default: "parry".
80
+ config.key_prefix = "parry"
81
+
82
+ # Register the Rack::Attack hooks on boot. Default: true.
83
+ # Set to false to call Parry.install! yourself.
84
+ config.auto_install = true
85
+
86
+ # Record blocked and throttled requests for the "Blocked hosts" page. Default: true.
87
+ config.track_events = true
88
+
89
+ # How many hosts that page keeps, oldest dropped first. Default: 1000.
90
+ # The page itself lists them 50 at a time.
91
+ config.max_tracked_hosts = 1_000
92
+
93
+ # Seconds a honeypot regex may spend on one path before it is abandoned.
94
+ # Default: 0.05.
95
+ config.regex_timeout = 0.05
96
+ end
97
+ ```
98
+
99
+ ## Redis persistence
100
+
101
+ Parry's rules and the hosts its honeypots caught are **data, not cache**. If the Redis holding them loses its
102
+ contents, your rules are gone and every host you blocked is free again. Two ways that happens:
103
+
104
+ **A Redis container without a volume.** Everything lives in the container's writable layer, so anything that
105
+ recreates the container - a redeploy, `docker compose up --force-recreate`, a host rebuild - wipes it. Give it a
106
+ volume and turn on the append-only file:
107
+
108
+ ```yaml
109
+ services:
110
+ redis:
111
+ image: redis:7-alpine
112
+ command: redis-server --appendonly yes
113
+ volumes:
114
+ - ./tmp/redis:/data
115
+ ```
116
+
117
+ With Kamal, run Redis as an accessory with a directory of its own:
118
+
119
+ ```yaml
120
+ # config/deploy.yml
121
+ accessories:
122
+ redis:
123
+ image: redis:7-alpine
124
+ host: 203.0.113.10
125
+ cmd: redis-server --appendonly yes
126
+ directories:
127
+ - data:/data
128
+ ```
129
+
130
+ `directories` bind-mounts a directory on the host into the container, so the data outlives any container that
131
+ gets replaced. Without it the accessory keeps everything inside the container, and the first
132
+ `kamal accessory reboot redis` - or anything else that recreates it - starts from an empty database.
133
+
134
+ **A Redis used as a cache.** With a `maxmemory` limit and an eviction policy like `allkeys-lru`, Redis quietly
135
+ drops keys when it fills up, and it does not exempt yours. Either run the instance with `noeviction`, or point
136
+ Parry somewhere else with `PARRY_REDIS_URL` - a separate database on the same server is enough:
137
+
138
+ ```ruby
139
+ Parry.configure do |config|
140
+ config.redis = Redis.new(url: ENV.fetch("PARRY_REDIS_URL"))
141
+ end
142
+ ```
143
+
144
+ To check what a running server is doing:
145
+
146
+ ```sh
147
+ redis-cli config get appendonly
148
+ redis-cli config get maxmemory-policy
149
+ ```
150
+
151
+ ## Rule types
152
+
153
+ | Type | Effect |
154
+ | --- | --- |
155
+ | Blocklist | Requests from the IP or CIDR range get `403 Forbidden` |
156
+ | Safelist | Requests from the IP or CIDR range skip every blocklist, throttle and honeypot |
157
+ | Throttle | More than *limit* requests per *period* seconds from one IP get `429 Too Many Requests`, optionally only under a path prefix |
158
+ | Honeypot | Requesting the path - by prefix or regex - blocklists the host, so all of its later requests get `403 Forbidden` too |
159
+
160
+ Both IPv4 and IPv6 addresses and ranges are accepted.
161
+
162
+ ### Honeypots
163
+
164
+ A honeypot turns a path into bait. The first request to it gets a `403`, and the host is blocked everywhere
165
+ until you release it:
166
+
167
+ ```
168
+ GET /.env from 203.0.113.9 -> 403, host caught
169
+ GET / from 203.0.113.9 -> 403, still blocked
170
+ GET / from 198.51.100.1 -> 200
171
+ ```
172
+
173
+ Point honeypots at paths a real visitor never requests - `/.env`, `/wp-login.php`, `/.git/config` - and they will
174
+ catch the scanners probing for them. Caught hosts show up under **Blocked hosts** as `Caught`, together with
175
+ the path that caught them, and the Unblock button releases them.
176
+
177
+ A honeypot matches its path in one of two ways:
178
+
179
+ **Prefix** (the default) - `/wp-login.php` also catches `/wp-login.php.bak`, and `/admin` catches everything
180
+ below `/admin`.
181
+
182
+ **Regular expression** - one rule can cover a family of probes:
183
+
184
+ | Pattern | Catches |
185
+ | --- | --- |
186
+ | `\.(env\|git)` | `/.env`, `/.git/config`, `/config/.env` |
187
+ | `\A/(wp-admin\|wp-login)` | `/wp-admin/`, `/wp-login.php` |
188
+ | `\.(php\|asp\|aspx)\z` | any request ending in a PHP or ASP extension |
189
+
190
+ The pattern is ordinary Ruby regex syntax and is **matched anywhere in the path unless you anchor it** with `\A`
191
+ or `\z`. Bear in mind the path always starts with `/`, so `\Awp-` never matches anything - you want `\A/wp-`.
192
+
193
+ Whichever you choose, pick it carefully: anything a legitimate visitor can reach blocks them, so `/` as a prefix
194
+ honeypots your entire audience. Safelist your own address before arming a broad one.
195
+
196
+ Regexes are compiled with a timeout (`config.regex_timeout`, 50ms by default) because they run against every
197
+ request path. A pattern that backtracks catastrophically is abandoned and logged rather than allowed to hang the
198
+ request, and a pattern that does not compile is rejected by the form.
199
+
200
+ ## Export and import
201
+
202
+ The rules list has **Export** and **Import** buttons. Export downloads every rule as JSON; import reads that
203
+ file back, either adding to the rules already there or replacing them outright.
204
+
205
+ ```json
206
+ {
207
+ "format": 1,
208
+ "parry_version": "0.1.0",
209
+ "exported_at": 1787390429,
210
+ "rules": [
211
+ {"id": "3f2a...", "kind": "honeypot", "path": "\\.(env|git)", "path_match": "regex", "name": "Config probe"}
212
+ ]
213
+ }
214
+ ```
215
+
216
+ Use it to keep a backup of a hand-built rule set, to move rules from staging to production, or to keep the
217
+ same honeypots across several applications. Rules keep their ids, so re-importing the same file updates the
218
+ rules it already created instead of duplicating them.
10
219
 
11
- Install the gem and add to the application's Gemfile by executing:
220
+ ## Starter honeypot rules
12
221
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
222
+ Import this JSON to set up a starter set of honeypots that catch common scanner probes:
223
+
224
+ ```json
225
+ {
226
+ "format": 1,
227
+ "parry_version": "0.1.0",
228
+ "exported_at": 1792154664,
229
+ "rules": [
230
+ {"id": "81b72099bb9ee0b8", "kind": "honeypot", "path": "/wp-admin", "path_match": "prefix", "created_at": 1787485029},
231
+ {"id": "1cab2f085efd96c2", "kind": "honeypot", "path": "/wp-content", "path_match": "prefix", "created_at": 1787485038},
232
+ {"id": "319a068bd3ecf2a8", "kind": "honeypot", "path": "/*.php", "path_match": "regex", "created_at": 1787485046},
233
+ {"id": "45a6a5aa4ce9d553", "kind": "honeypot", "path": "/wp-includes/", "path_match": "prefix", "created_at": 1789592763},
234
+ {"id": "981ed0b90c9b82ac", "kind": "honeypot", "path": "\\.env", "path_match": "regex", "created_at": 1789592836},
235
+ {"id": "774556062efe2222", "kind": "honeypot", "path": "\\.ini", "path_match": "regex", "created_at": 1789592872},
236
+ {"id": "ca7a15b798c13b50", "kind": "honeypot", "path": "\\.action", "path_match": "regex", "created_at": 1789592892},
237
+ {"id": "ad18ef756734ab61", "kind": "honeypot", "path": "/.git", "path_match": "prefix", "created_at": 1789632673},
238
+ {"id": "fd4f5f92ceb0f812", "kind": "honeypot", "path": "wlwmanifest\\.xml", "path_match": "regex", "created_at": 1789632707},
239
+ {"id": "ea747ec75c548815", "kind": "honeypot", "path": "/static", "path_match": "prefix", "created_at": 1789632734},
240
+ {"id": "f91a2c1cc24a7722", "kind": "honeypot", "path": "/wp-login.php", "path_match": "prefix", "created_at": 1789632748},
241
+ {"id": "9a81543ea9c5df68", "kind": "honeypot", "path": "/cms", "path_match": "prefix", "created_at": 1789632834},
242
+ {"id": "5c0b3f04946a1eb9", "kind": "honeypot", "path": "/backup", "path_match": "prefix", "created_at": 1792154602},
243
+ {"id": "61a8b763a94d5154", "kind": "honeypot", "path": "/sftp-config.json", "path_match": "prefix", "created_at": 1792154628},
244
+ {"id": "67b7f40a073d3234", "kind": "honeypot", "path": "/version", "path_match": "prefix", "created_at": 1792154643},
245
+ {"id": "70b36a2230e03379", "kind": "honeypot", "path": "/docker-compose.yml", "path_match": "prefix", "created_at": 1792154659}
246
+ ]
247
+ }
248
+ ```
249
+
250
+ The set covers WordPress, `.env`, `.git`, backup files, and other common probes. Import it from the GUI or
251
+ from the Rails console:
252
+
253
+ ```ruby
254
+ json = File.read("starter-honeypots.json")
255
+ Parry::RuleTransfer.import(json, replace: false)
15
256
  ```
16
257
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
258
+ **An import is all or nothing.** Every rule in the file is validated as it is applied, and if any one of them is
259
+ rejected the whole import is rolled back and each problem is listed - a file with a typo in it can never leave
260
+ you with half a rule set. That holds for "replace" too: the rules you have now are only let go once the
261
+ replacement is known to be good.
262
+
263
+ The same thing from a console, if you would rather script it:
264
+
265
+ ```ruby
266
+ File.write("rules.json", Parry::RuleTransfer.export_json)
267
+
268
+ result = Parry::RuleTransfer.import(File.read("rules.json"), replace: true)
269
+ result.ok? # => true
270
+ result.imported # => 3
271
+ result.errors # => []
272
+ ```
273
+
274
+ Blocked and caught hosts are deliberately not part of an export - they are a record of what happened on one
275
+ server, not configuration.
276
+
277
+ ## How it works
278
+
279
+ Parry registers exactly one safelist and one blocklist check with Rack::Attack, plus one real
280
+ `Rack::Attack.throttle` per throttle rule. Rules are stored in a Redis hash next to a version counter that is
281
+ bumped on every write, and caught hosts live in a hash of their own that bumps the same counter.
282
+ The safelist check runs on every request and compares that counter against the snapshot
283
+ the process is holding, reloading the rules when it changed - so an edit made in one process is picked up by all
284
+ of them, at the cost of one Redis `GET` per request.
18
285
 
19
- ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
286
+ Rules you define yourself in an initializer keep working; Parry only adds to them.
287
+
288
+ If Redis is unreachable, Parry logs the error and keeps serving the last snapshot it loaded rather than taking
289
+ requests down with it.
290
+
291
+ ## Unblocking yourself from the Rails console
292
+
293
+ If a rule locks you out of the Parry GUI, open your application's Rails console and unblock your IP address:
294
+
295
+ ```ruby
296
+ Parry.unblock("203.0.113.4")
21
297
  ```
22
298
 
23
- ## Usage
299
+ This removes Parry blocklist rules covering the address, releases it from any honeypot, and resets its current
300
+ throttle counters.
301
+
302
+ ## Things worth knowing
24
303
 
25
- TODO: Write usage instructions here
304
+ - **The rules apply to the GUI too.** A throttle without a path prefix counts your own visits to the Parry pages,
305
+ and if your address ends up blocklisted you will not be able to reach them. Safelisting the address you
306
+ administer from avoids locking yourself out.
307
+ - **A caught host stays blocked when you delete the honeypot rule.** The rule is the sensor, the caught host is
308
+ the result: removing the sensor does not vouch for the hosts it already caught. Release them individually from
309
+ the Blocked hosts page.
310
+ - **Safelist yourself before arming a broad honeypot.** The safelist check runs first, so a safelisted address can
311
+ never be caught - it is the way back in if a honeypot turns out to cover more than you meant.
312
+ - **Unblock only removes Parry's own rules.** Blocklists written in your initializer are static Ruby, so the GUI
313
+ cannot delete them - it reports the throttle counters it reset instead.
314
+ - **Unblock clears the current throttle window** for every registered throttle, including throttles your own
315
+ initializer defines, and releases the host from any honeypot. Counters from `Fail2Ban` and `Allow2Ban` are not
316
+ touched.
317
+ - **Use a shared cache store.** With the default in-memory store each process counts separately and unblocking
318
+ only affects the process that handled the request.
319
+ - **Give Redis a volume.** Rules and caught hosts do not survive a container that gets recreated without one -
320
+ see [Redis persistence](#redis-persistence).
26
321
 
27
322
  ## Development
28
323
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
324
+ Parry's test suite runs against a real Redis and a dummy Rails application:
30
325
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
326
+ ```sh
327
+ docker compose up -d # Redis on port 6380
328
+ bin/setup
329
+ bundle exec rake # tests + standard
330
+ ```
331
+
332
+ To click through the GUI, boot the dummy application:
333
+
334
+ ```sh
335
+ REDIS_URL=redis://127.0.0.1:6380/2 bundle exec puma test/dummy/config.ru -p 3210
336
+ # then open http://127.0.0.1:3210/parry
337
+ ```
32
338
 
33
339
  ## Contributing
34
340
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/parry. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/parry/blob/master/CODE_OF_CONDUCT.md).
341
+ Bug reports and pull requests are welcome at https://github.com/gregmolnar/parry.
36
342
 
37
343
  ## License
38
344
 
39
345
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
-
41
- ## Code of Conduct
42
-
43
- Everyone interacting in the Parry project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/parry/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -3,7 +3,9 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "minitest/test_task"
5
5
 
6
- Minitest::TestTask.create
6
+ Minitest::TestTask.create do |t|
7
+ t.test_globs = ["test/**/*_test.rb"]
8
+ end
7
9
 
8
10
  require "standard/rake"
9
11
 
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+ layout "parry/application"
7
+ end
8
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class BlockedHostsController < ApplicationController
5
+ PER_PAGE = 50
6
+ # Fallback window for reading the tracker when host tracking is uncapped.
7
+ MAX_LISTED = 1000
8
+
9
+ def index
10
+ @rule_set = Parry.store.rule_set
11
+ hosts = hosts_with_caught(tracked_hosts)
12
+
13
+ @total = hosts.size
14
+ @total_pages = [(@total / PER_PAGE.to_f).ceil, 1].max
15
+ @page = requested_page(@total_pages)
16
+ @offset = (@page - 1) * PER_PAGE
17
+ @hosts = hosts[@offset, PER_PAGE] || []
18
+ end
19
+
20
+ def unblock
21
+ ip = params[:ip].to_s
22
+
23
+ if ip.empty?
24
+ redirect_to blocked_hosts_path(page: page_param), alert: "No host given."
25
+ return
26
+ end
27
+
28
+ was_caught = Parry.honeypots.caught?(ip)
29
+ removed = Parry.unblock(ip)
30
+
31
+ # Back to the page they were looking at, not the top of the list.
32
+ redirect_to blocked_hosts_path(page: page_param), notice: unblocked_notice(ip, removed, was_caught)
33
+ end
34
+
35
+ private
36
+
37
+ # The tracker trims itself to max_tracked_hosts, so reading that many covers
38
+ # everything it holds.
39
+ def tracked_hosts
40
+ limit = Parry.config.max_tracked_hosts.to_i
41
+
42
+ Parry.tracker.all(limit: limit.positive? ? limit : MAX_LISTED)
43
+ end
44
+
45
+ def requested_page(total_pages)
46
+ page = params[:page].to_i
47
+ return 1 if page < 1
48
+
49
+ [page, total_pages].min
50
+ end
51
+
52
+ # Kept out of the URL when it is the first page.
53
+ def page_param
54
+ page = params[:page].to_i
55
+
56
+ (page > 1) ? page : nil
57
+ end
58
+
59
+ def unblocked_notice(ip, removed, was_caught)
60
+ changes = []
61
+ changes << "removed #{removed.size} blocklist #{"rule".pluralize(removed.size)}" if removed.any?
62
+ changes << "released it from the honeypot" if was_caught
63
+ changes << "reset its throttle counters" if changes.empty?
64
+
65
+ "Unblocked #{ip} and #{changes.to_sentence}."
66
+ end
67
+
68
+ def hosts_with_caught(tracked)
69
+ seen = tracked.map(&:ip)
70
+
71
+ caught = @rule_set.caught_hosts.each_value.reject { |host| seen.include?(host.ip) }.map do |host|
72
+ BlockedHost.new(
73
+ ip: host.ip,
74
+ match_type: "honeypot",
75
+ rule: host.rule.present? ? "Honeypot: #{host.rule}" : "Honeypot",
76
+ path: host.path,
77
+ first_seen: host.caught_at&.to_i,
78
+ last_seen: host.caught_at&.to_i
79
+ )
80
+ end
81
+
82
+ (tracked + caught).sort_by { |host| -(host.last_seen&.to_i || 0) }
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ class RulesController < ApplicationController
5
+ before_action :set_rule, only: [:edit, :update, :destroy]
6
+
7
+ def index
8
+ @rules = Parry.store.all
9
+ end
10
+
11
+ def new
12
+ @rule = Rule.new(kind: allowed_kind(params[:kind]))
13
+ end
14
+
15
+ def create
16
+ @rule = Rule.new(rule_params)
17
+
18
+ if Parry.store.save(@rule)
19
+ Parry.adapter.sync!(force: true)
20
+ redirect_to rules_path, notice: "Rule created."
21
+ else
22
+ render :new, status: :unprocessable_entity
23
+ end
24
+ end
25
+
26
+ def edit
27
+ end
28
+
29
+ def update
30
+ @rule.assign_attributes(rule_params)
31
+
32
+ if Parry.store.save(@rule)
33
+ Parry.adapter.sync!(force: true)
34
+ redirect_to rules_path, notice: "Rule updated."
35
+ else
36
+ render :edit, status: :unprocessable_entity
37
+ end
38
+ end
39
+
40
+ def export
41
+ send_data RuleTransfer.export_json,
42
+ filename: RuleTransfer.filename,
43
+ type: "application/json",
44
+ disposition: "attachment"
45
+ end
46
+
47
+ def import
48
+ end
49
+
50
+ def perform_import
51
+ @result = import_result
52
+
53
+ if @result.ok?
54
+ Parry.adapter.sync!(force: true)
55
+ redirect_to rules_path, notice: import_notice(@result)
56
+ else
57
+ render :import, status: :unprocessable_entity
58
+ end
59
+ end
60
+
61
+ def destroy
62
+ Parry.store.delete(@rule.id)
63
+ Parry.adapter.sync!(force: true)
64
+ redirect_to rules_path, notice: "Rule deleted."
65
+ end
66
+
67
+ private
68
+
69
+ def import_result
70
+ payload = import_payload
71
+
72
+ if payload.blank?
73
+ return RuleTransfer::Result.new(
74
+ imported: 0,
75
+ errors: ["Choose a file to import, or paste one in."],
76
+ replaced: false
77
+ )
78
+ end
79
+
80
+ RuleTransfer.import(payload, replace: params[:mode] == "replace")
81
+ end
82
+
83
+ def import_payload
84
+ file = params[:file]
85
+ return file.read(RuleTransfer::MAX_BYTES + 1).to_s if file.respond_to?(:read)
86
+
87
+ params[:pasted].to_s
88
+ end
89
+
90
+ def import_notice(result)
91
+ counted = "#{result.imported} #{"rule".pluralize(result.imported)}"
92
+
93
+ result.replaced? ? "Replaced every rule with the #{counted} from the file." : "Imported #{counted}."
94
+ end
95
+
96
+ def set_rule
97
+ @rule = Parry.store.find(params[:id])
98
+ redirect_to rules_path, alert: "Rule not found." unless @rule
99
+ end
100
+
101
+ def rule_params
102
+ params.require(:rule).permit(:kind, :value, :name, :limit, :period, :path, :path_match).to_h.symbolize_keys
103
+ end
104
+
105
+ def allowed_kind(kind)
106
+ Rule::KINDS.include?(kind) ? kind : "honeypot"
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Parry
4
+ module ApplicationHelper
5
+ def parry_time(time)
6
+ return "—" if time.nil?
7
+
8
+ tag.span("#{time_ago_in_words(time)} ago", title: time.strftime("%Y-%m-%d %H:%M:%S %Z"))
9
+ end
10
+
11
+ def parry_period(seconds)
12
+ return "—" if seconds.nil?
13
+
14
+ distance_of_time_in_words(seconds.to_i)
15
+ end
16
+
17
+ def parry_kind_label(kind)
18
+ tag.span(kind.to_s.titleize, class: "parry-tag parry-tag--#{kind}")
19
+ end
20
+ end
21
+ end