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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e471289a3e66fdd541918f0a5891c24062af5609ca7fd958d75b4927660ba679
|
|
4
|
+
data.tar.gz: e9545bd991b262a57de839c253cc594ecca37ad886fd9a2fcc2e35b5aa0ee5d7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 135b9c7801d8e82bdced96eb1a578f5e60fc7ee256aaac6f6eb5dfe2a4274c3419a4073fa6585ff5342a224041cd8db339653c7321ea715ca526c0f018326aa6
|
|
7
|
+
data.tar.gz: 194b02b4a325cbdc86f2cc536a0d09359e2ced883787625992b7d49a15fd10b4a78a71fa782510e386ed85d63fd9fb7f82102c804a919cd5e70a1b74d7d81525
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.2.0] - 2026-10-16
|
|
4
4
|
|
|
5
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
+
## Starter honeypot rules
|
|
12
221
|
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
Parry's test suite runs against a real Redis and a dummy Rails application:
|
|
30
325
|
|
|
31
|
-
|
|
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
|
|
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
|
@@ -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
|