Request-level protection for WSGI applications: signatures, rate limiting, honeypots, IP reputation and a bot challenge, inspected before your app sees the request.
Project description
ZeroV4
Request-level protection for WSGI applications. Signatures, rate limiting, honeypots, IP reputation, and a bot challenge — all applied before your app sees the request.
from flask import Flask
from zerov4 import arx
app = Flask(__name__)
@app.route("/")
def index():
return "my site"
arx.run(app) # asks for a port, then serves — protected
ZeroV4 asks for a port, a debug flag and a protection level, then serves your app with every request inspected first.
Install
pip install zerov4
Optional extras:
pip install zerov4[redis] # Redis-backed storage
pip install zerov4[geoip] # country lookup for blocked IPs
pip install zerov4[prod] # waitress, for serving in production
pip install zerov4[all]
Core dependency: werkzeug. That's the whole list. A security package that pulls in half of PyPI has a larger attack surface than the attacks it stops.
Early development
ZeroV4 moves fast right now. Check for updates before you rely on it:
pip install --upgrade zerov4
In production, pin the version and upgrade deliberately. This is a security layer sitting in front of your app — an unplanned update is its own kind of outage.
Your first run
Make a file called run.py:
from flask import Flask
from zerov4 import arx
app = Flask(__name__)
@app.route("/")
def index():
return "<h1>my site</h1>"
@app.route("/search")
def search():
from flask import request
return f"you searched: {request.args.get('q')}"
arx.run(app)
Run it:
python run.py
Answer the questions (Enter accepts every default), then open http://localhost:5000:
| Try this | You should get |
|---|---|
/ |
your page, normally |
/search?q=hello |
your page, normally |
/search?q=union select 1 |
429 — blocked before your route ran |
/.env |
403 — honeypot, instant ban |
/panel |
the SOC panel |
Banned yourself while testing? That is the system working. Let yourself back in:
python -c "from zerov4 import arx; arx.unblock('127.0.0.1')"
Or delete the logs/ folder to reset everything.
Throughout this README,
myappmeans your own file, e.g.myapp.pyin the same folder. It is not something you install. If your app lives inshop.pyasapp, then it'sfrom shop import appandarx.run("shop:app"). Avoid naming your file after a package that already exists on PyPI — Python will import that one instead of yours.
Usage
The quick way
If your app is in the same file, hand it over directly:
from zerov4 import arx
arx.run(app) # menu, then serve
arx.run(app, port=8080) # port answered, so not asked
arx.run(interactive=False) # no questions at all
If it lives in another file — say myapp.py, with the app named app —
import it, or let ZeroV4 do the importing:
from myapp import app # your file, in this folder
arx.run(app)
arx.run("myapp:app") # module:attribute, same thing
Under gunicorn
run() serves the app itself. If gunicorn/uwsgi is doing that, wrap instead:
# wsgi.py
from zerov4 import arx
from myapp import app
application = arx.wrap(app)
gunicorn wsgi:application -w 4
As middleware
from flask import Flask
from zerov4 import ZeroGuard, make_config
app = Flask(__name__)
app.wsgi_app = ZeroGuard(app.wsgi_app, cfg=make_config("balanced"))
It's plain WSGI, so Django, Bottle, Pyramid and anything else that speaks the protocol work the same way.
From the shell
zerov4 init # configure interactively, write zerov4.toml
zerov4 run myapp:app # serve — myapp.py in this folder, app inside it
zerov4 status # what the firewall knows
zerov4 unblock 1.2.3.4 # let an address back in
zerov4 whitelist 10.0.0.5
zerov4 signatures # what's loaded
zerov4 feedback events # every block/throttle, with an id
zerov4 feedback wrong ID # mark one as a false positive
Run these from the folder your app is in — zerov4 run imports it the same
way Python would.
Protection levels
permissive |
balanced (default) |
paranoid |
|
|---|---|---|---|
| Ban threshold | 20 hits | 10 hits | 10 hits |
| Rate limit | 300 / 10s | 100 / 10s | 10 / 1s |
| 404s count as attacks | no | no | yes |
| Non-JSON body is an attack | no | no | no (opt-in) |
| Fuzzy keyword matching | no | no | yes |
| Session bound to IP | no | no | yes |
| Bot challenge | off | on | on |
| Pattern analysis | off | on | on |
| Blocking throttle | no | no | yes |
Balanced is tuned for a site with real visitors and is what you want unless you have a reason.
Paranoid treats ordinary traffic as hostile: every 404 counts toward a ban, keyword matching goes fuzzy, and a session is bound to its IP — so a phone moving from WiFi to cellular reads as session theft. It is for a target you control, not a public site. ZeroV4 asks you to confirm before enabling it.
Non-JSON bodies are never an attack, paranoid included. An HTML form posts application/x-www-form-urlencoded and a file upload posts multipart/form-data; treating either as hostile bans the visitors of any site that has a form. If you run a JSON-only API, where a non-JSON body really is anomalous, ask for it:
cfg = make_config("paranoid", non_json_body_is_attack=True)
Even then, a request that declares a form content type is exempt — the rule catches a body that lies about being JSON, not one that never claimed to be.
What it does
Signatures. Keyword matching plus stateful detect() functions that count across a time window. Keywords are word-bounded, so select does not match /products/selectbox.
Escalating throttle. Hit 1 costs 2 seconds, hit 2 costs 4, hit 9 costs 300 (capped). Hit 10 is a permanent ban. Throttling returns 429 with Retry-After immediately — it does not hold a worker asleep.
IP reputation. Banned addresses are remembered and blocked on sight next time, without earning hits again. Entries expire when they haven't been seen for a while, so an address that changed hands isn't blocked forever.
Honeypots. /.env, /wp-admin, /phpmyadmin and friends. One request is an instant ban.
Bot challenge. Throttled browsers get an arithmetic question plus pointer-movement analysis instead of a flat 429, so a real person can prove it and carry on. Touch devices are not punished for having no mousemove.
Pattern analysis. Correlates across IPs: coordinated attacks, slow reconnaissance, multi-category probing, rapid escalation.
Feedback loop. Mark a decision wrong; after enough marks for the same signature, ZeroV4 tells you which file to look at. It never edits its own rules, never unbans on its own, never weakens anything by itself. The decision is yours.
SOC panel. Live stats, blocked list, reputation, whitelist, event log, suggestions. Password-protected, hash in .env.
Configuration
from zerov4 import make_config, arx
cfg = make_config(
"balanced",
port=8080,
trusted_proxies=("10.0.0.0/8",), # required behind nginx/Cloudflare
redis_url="redis://localhost:6379",
geoip_db="GeoLite2-Country.mmdb",
)
arx.run(app, cfg=cfg)
Or zerov4.toml:
[zerov4]
level = "balanced"
port = 5000
storage = "auto"
panel_enabled = true
trusted_proxies = ["10.0.0.0/8"]
Or the environment: ZEROV4_PORT=8080 ZEROV4_LEVEL=paranoid.
Behind a proxy
Set trusted_proxies if anything sits in front of your app. Forwarding headers are only honoured when the direct peer is on that list. Without it, an attacker sends X-Real-IP: <your customer> and gets your customer banned while staying clean. With no proxy configured, REMOTE_ADDR is used and headers are ignored.
Hooks
ZeroV4 does not touch your machine's network, kill processes, or click anything. If you want that, wire it up:
def on_coordinated(ips, reason):
print(f"{len(ips)} attackers: {reason}")
# your own response here
cfg = make_config("balanced",
on_coordinated_attack=on_coordinated,
on_ban=lambda ip, attack, entry: notify(ip),
on_threat_cleared=lambda: print("clear"))
Custom signatures
A signature is a module with a NAME and either KEYWORDS, a detect() function, or both.
# signatures/my_rule.py
NAME = "Admin Panel Probing"
KEYWORDS = ["/wp-login", "/administrator"]
def detect(line, parsed, now, counters):
ip = parsed["ip"]
if "admin" in line.lower():
counters["admin_probe"][ip].append(now)
counters["admin_probe"][ip] = [
t for t in counters["admin_probe"][ip] if now - t <= 60
]
if len(counters["admin_probe"][ip]) >= 5:
return f"{len(counters['admin_probe'][ip])} admin probes in 60s"
return None
Point at your own directory:
cfg = make_config("balanced", signatures_dir="./signatures")
A signature that raises is logged and quarantined for the run, not allowed to take the request down with it.
Loading a signature directory imports and executes those .py files. Don't point it at a directory other people can write to.
Panel
zerov4 init # asks for a panel password
The password is never written to disk — only a PBKDF2-SHA256 hash, into .env at mode 0600. Add .env to your .gitignore; ZeroV4 warns you if you haven't.
Panel lives at /panel by default. It fails closed: no password set means no logins, not an open door.
Badge
Optional. Two lines, and it's yours to place:
from zerov4 import arx
from myapp import app
arx.badge(app) # registers the template helper
arx.run(app)
<body>
...
{{ zerov4_badge() }}
</body>
A small grey pill with white text, in the corner, scrolling with the page.
{{ zerov4_badge("bottom-right") }} <!-- top-right (default), top-left,
bottom-right, bottom-left -->
{{ zerov4_badge(dark=False) }} <!-- lighter pill, for a pale page -->
{{ zerov4_badge(bg="#1e293b", fg="#e2e8f0") }} <!-- your own colours -->
Not using Flask? arx.badge() with no argument returns the markup; paste
it wherever you like.
ZeroV4 does not inject the badge into your responses. Rewriting your HTML on the way out breaks gzip, breaks streaming, breaks Content-Length — and when it breaks, it breaks your site. Where the badge goes is your call.
The badge doesn't name your protection level, and it shouldn't: the signatures are readable on PyPI, so which rules you're running is the one detail worth keeping to yourself.
API
from zerov4 import arx
arx.run(app) # serve
arx.wrap(app) # wrap only (gunicorn)
arx.stop()
arx.status() # stats dict
arx.blocked() # list of blocked IPs
arx.reputation() # learned bad IPs
arx.attacks(20) # recent detections
arx.is_blocked("1.2.3.4")
arx.unblock("1.2.3.4")
arx.ban("1.2.3.4")
arx.whitelist("10.0.0.5") # never blocked
arx.whitelist() # list
arx.events() # firewall decisions, with ids
arx.wrong(event_id) # mark a false positive
arx.suggestions() # signatures worth reviewing
arx.set_panel_password()
arx.reload_signatures()
arx.badge(app) # register {{ zerov4_badge() }}
arx.badge() # just the markup
Notes
Locked out? A whitelisted address is never blocked by any rule. Add yours before tightening anything: zerov4 whitelist YOUR.IP. Whitelisting an already-banned IP clears the ban.
Storage. Redis if reachable, JSON files otherwise, both automatic. storage="redis" fails loudly instead of silently degrading — a security system whose bans quietly stop persisting is worse than one that tells you.
Multiple workers. Under gunicorn -w 4, use Redis. With JSON, each worker keeps its own view and rate limits count per worker.
Failure mode. If ZeroV4 itself crashes mid-inspection, the request is allowed and the error is logged. A bug here should cost you protection, not uptime.
Threads. A background agent handles pattern analysis and cleanup, and a saver persists state periodically. Both are daemon threads that stop cleanly.
Status
Beta. Test it against your own traffic before putting it in front of anything that matters — start at permissive or balanced, watch what gets blocked, then tighten. A misconfigured WAF either bans your users or waves the attacker through, and only your traffic can tell you which one you have.
Credits
Derived from Autonomous Agent Zero-V4 by Barış (BRSX-Labs).
Apache 2.0.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file zerov4-0.2.2.tar.gz.
File metadata
- Download URL: zerov4-0.2.2.tar.gz
- Upload date:
- Size: 236.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ef48b9b1ba031b343317eee53d476df0d8bddd27bc4c595afecc1879e06030d6
|
|
| MD5 |
6a038282d38a8ef88d45ecd378067a86
|
|
| BLAKE2b-256 |
68fa02410806b699faf9bfca30d197a2d959546e870c393ef3688de52db3c8b5
|
File details
Details for the file zerov4-0.2.2-py3-none-any.whl.
File metadata
- Download URL: zerov4-0.2.2-py3-none-any.whl
- Upload date:
- Size: 544.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.10.0
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
3e11a8b523aec06c865af1e4be331994f3c48b0174ab558ab3f8ef6fd33ccb66
|
|
| MD5 |
e3e0447aa55d3730de938ebe8b09b9ee
|
|
| BLAKE2b-256 |
836e9e403f1849f01e813ada4cfad1790aafe3f556f2f11550d0c06f5404a903
|