Security Overview
This page describes EzyShield's security model from a user's perspective. For detailed threat analysis, see the internal SECURITY-REVIEW (available in the ezy-shield repository).
Architecture
logs (SSH, Nginx)
↓
[ Collector ] — tail file, read journald
↓
[ Parser ] — structured event (IP, method, status)
↓
[ Rule Engine ] — offline scoring (always runs, no network)
↓
[ AI (optional) ] — Anthropic/OpenAI/Ollama (only for ambiguous events)
↓
[ Decision Engine ] — make ban/allow/defer decision
↓
[ Enforcer (privilege-separated) ] — apply bans (nftables, Cloudflare)Key principle: the main daemon never holds elevated privileges. Firewall mutations only happen through a separate ezyshield-enforcer binary that holds CAP_NET_ADMIN.
Anti-lockout guarantee
EzyShield has a hard rule: your active SSH session and admin CIDRs can never be banned, even if they match an attack pattern.
Before any ban is written to the firewall, two independent checks run in sequence:
- Allowlist check: the target IP is matched against the static allowlist — your configured
allowlist,admin_cidrsfrom policy.yaml, and the SSH peer captured when the daemon started. - Live SSH re-check: the target IP is matched against the SSH peers active right now — from the kernel's connection table (
/proc/net/tcpand/proc/net/tcp6, remote ends of established connections to the sshd port(s) — works under systemd, no environment needed; sshd ports read fromsshd_config, fallback 22) and, in interactive contexts, from the currentSSH_CLIENTenv var.
If either check matches, the ban is rejected.
Manual bans (ezyshield ban) pass the exact same guards — including the ban rate limit — and refused attempts are recorded in the audit log.
This is enforced in code, not a rule. No misconfigured threshold can lock you out.
The check runs twice, in independent layers: the decision engine filters first, and a single gate ahead of all enforcement backends re-checks every ban and every reconcile before it can reach nftables or any edge platform. Even a backend with no allowlist logic of its own can never receive a protected address — including via a sync that would re-introduce it.
Both layers judge by the same peer-immunity predicate: with anti_lockout.require_authenticated on, the gate narrows to authenticated peers exactly as the decision engine does, so a ban the engine decides is one the gate will apply. The two can still briefly disagree about a fast-reconnecting attacker — the engine reads a short-lived cached peer list, the gate probes live — and then the engine has already recorded the ban when the gate refuses to apply it. That refusal is not an enforcer failure and raises no alert: the ban stays recorded, and the daemon retries enforcement shortly afterwards, through the same gate and with the ban's remaining time, until the connection is gone or the retry budget runs out (a warning, an enforce_deferred_exhausted audit entry, and the periodic reconcile picks it up). A ban lifted or expired in the meantime is never re-applied, and an operator's session is refused on every retry — nothing on this path can apply a ban the gate would not.
The live SSH re-check protects a connection, not an address forever. A bruteforcer that reconnects faster than the peer table is re-read keeps an established connection visible at every evaluation, so each attempt in its threshold window is (correctly) refused. To close the gap where such a burst would otherwise never be banned, an IP whose ban was refused solely because of an active SSH connection is re-evaluated shortly after that connection closes — from its still-in-window event history, through the identical guards (allowlist, a fresh live SSH-peer probe, ban-rate limit). If the connection is still established at re-check time — a genuine operator session — the refusal simply repeats and nothing is banned. A ban can therefore only ever result from a full check that found no established SSH connection at that moment; the operator's session remains unbannable for as long as it is open.
Allowlist supremacy
The allowlist is checked FIRST, before any rule engine decision. An allowlisted IP cannot be banned by any rule, AI decision, or manual ban attempt.
It also wins over bans that already exist. ezyshield allow <ip-or-cidr> lifts every active ban the entry covers — from the kernel, the edge platforms and the store, each one audited as an unban — and the reconcile never re-applies a ban to an allowed address. Reputation feeds honour the runtime allowlist the same way the policy file's allowlist is honoured, and disable --all empties the feed sets along with the ban sets.
In the kernel, the @allowed sets are accepted before any drop in every chain EzyShield installs (prerouting, input and forward): an accept in the prerouting hook ends only that chain, and a packet for a local service still traverses the input hook, so the same accept-before-drop pair sits there too.
allowlist:
- 10.0.0.0/8 # internal network
- 198.51.100.7 # a specific host
admin_cidrs:
- 192.0.2.0/24 # admin ranges, re-checked before every banRate limiting
A broken rule or poisoned feed cannot ban the entire internet. The max_bans_per_minute cap (default 30) rejects excess bans with an explicit error — never silently, never by dropping the limit. Escalation bans re-blocking an IP whose previous ban ended within escalation_exempt_window (default 24h) are exempt from this cap, so a returning repeat offender is never let back in while the rate limit is saturated; first-time bans always count against the cap.
Detection resilience
Detection is only useful if it keeps running. Each log source (a file tail, a journalctl reader, a Docker container stream) is watched by its own supervisor. If a collector hits a transient fault — a log file briefly missing at startup, a logrotate reopen that runs long, journald restarting and killing journalctl — the supervisor restarts that collector automatically with capped exponential backoff, so a routine operational hiccup can't silently disable detection on that source until the next daemon restart.
The supervisor distinguishes a genuine fault from a clean shutdown: on SIGTERM/SIGINT (context cancellation) collectors stop and are not restarted, so shutdown never hot-loops. A collector that fails repeatedly raises a critical notification naming the source, so a permanently broken input surfaces to you instead of retrying in silence. The other log sources keep running throughout — one failing collector never takes down the pipeline.
Secret handling
No secrets appear in:
- Config files (use
env:VAR_NAMEsyntax) - Log output
- Error messages
- AI prompts
- Audit trail
API tokens are resolved once at startup and never printed. If a secret is referenced in an error, the error is rewritten to omit it.
AI safety
When AI is enabled for ambiguous events (scores inside the configurable ambiguous_band):
- Schema validation: AI output is parsed into a structured type; malformed responses cause a fallback decision.
- Policy clamping: AI can only suggest within the ban thresholds and durations you configured. It cannot escalate beyond them.
- Target binding: a verdict can only name an IP that was in the analyzed batch. A hallucinating (or compromised) model naming any other address is discarded with a warning — the AI can never pick the ban target, only score IPs the logs actually observed.
- Audit trail: every AI verdict (source, score, reason) is persisted with the strike, so you can audit and override if needed.
- No prompt injection: Log lines are passed as data, never interpolated into instructions. The prompt is fixed and controlled.
Privilege separation
- Main daemon (
ezyshield): runs as unprivileged user, reads logs, makes decisions, communicates via unix socket - Enforcer (
ezyshield-enforcer): holdsCAP_NET_ADMINonly, accepts a fixed, typed verb set (ping,add,del,list,flush, and the allowlist verbs), mutates nftables in a safe, idempotent way. Every blocked-set element keeps its own timeout: the sets are created without nftables'auto-merge, because a merged interval carries a single timer and a neighbouring ban would silently rewrite yours (a permanent ban next to a five-minute one would expire in five minutes). A set left over from an older layout is rebuilt without the flag, elements preserved, the first time the helper starts
The enforcer is not a library. It's a separate process. The main daemon cannot directly modify the firewall.
The one grant that changes this: the docker group
Docker log collectors (and the docker exec watcher) read through the Docker Engine socket, and access to that socket is granted by membership in the docker group. That group is not a read permission — it is the Engine API. Any process that can reach it can start a container with the host filesystem mounted, which is root on the host.
So on a Docker host where the service user is in docker, the first bullet above no longer holds: the daemon is root-equivalent, and the systemd hardening on its unit (NoNewPrivileges, ProtectSystem=strict, seccomp) does not contain it, because the escape is to ask the Docker daemon — an unconfined root process — to do the work.
The group is therefore the last of three ways to read container logs, and EzyShield never grants it on its own. In privilege order:
A host log file. The container writes its access log to a bind-mounted host path, and a
kind: filecollector reads it. EzyShield gets no Docker access at all. Reach for this first.A read-only socket proxy. A filtering proxy in front of the Engine socket, published on
127.0.0.1, answers container logs and events and refuses container creation, exec and mounts. Pointdocker.hostat it:yamldocker: host: tcp://127.0.0.1:2375The daemon speaks the Engine API over loopback TCP and stays out of the
dockergroup. EzyShield opens no listener for this — the proxy is a container in your own stack, and it is the only thing that touches the socket. See Docker + nginx + WordPress for the compose snippet.The
dockergroup. Root-equivalent, as above. Only when neither of the other two fits.
How the choice is made:
ezyshield initoffers all three only when you configure at least one docker collector, pre-selects the least-privileged one that fits, and names the consequence of the group before asking for it.- Scripted installs pick one explicitly:
--docker-host tcp://127.0.0.1:2375(answers keycollectors.docker_host) or--docker-group(answers keycollectors.docker_group: true). Passing both is an error — the proxy replaces the group, it does not accompany it.--yesaccepts safe defaults, and neither grant is a default. - Choosing nothing still writes the collectors — they simply cannot read container logs until access exists.
ezyshield doctorwarns whenever the service user is indocker, and says whether anything in your configuration justifies it. Whendocker.hostis a TCP endpoint, doctor also probes it: it must answerGET /_pingand it must refusePOST /containers/create. An endpoint that accepts container creation is root-equivalent access to the host over the network, and that check FAILs.
To check and revoke the group:
getent group docker # is ezyshield listed?
sudo gpasswd -d ezyshield docker # revoke
sudo systemctl restart ezyshield # drop the group from the running daemonRevoking disables docker collectors unless one of the first two paths is in place.
docker.host accepts unix:///path (the default is unix:///var/run/docker.sock) and tcp://host:port. A TCP host must be a loopback IP literal; anything else is refused unless you also set docker.allow_remote: true, which accepts an unauthenticated plaintext Engine endpoint reachable from off-host. TLS to a remote engine is not supported.
No network listeners
EzyShield opens no network listener for control (the optional dashboard binds to a loopback address — 127.0.0.1 or ::1 — only, and refuses anything else). Control is via:
- CLI:
ezyshield ban,ezyshield list, etc. (local only) - Unix socket:
/run/ezyshield/ezyshield.sock(filesystem permissions)
Control-socket access tiers
Socket access is kernel-enforced by unix group membership; the daemon adds a verb allowlist on top (issue #212). Who can do what:
| Tier | Socket | Group | Verbs |
|---|---|---|---|
| Operator | ezyshield.sock | ezyshield | everything: read verbs plus ban, unban, allow, unallow, arm, disarm, disable_all, prune, feeds_refresh |
| Viewer | ezyshield-ro.sock | ezyshield-view | read only: status, list, list_allow, events, report, subscribe (watch), metrics, feeds_status |
Both sockets are mode 0660 and — like unix sockets in general — are NOT network listeners. The viewer allowlist is closed and deny-by-default: a verb added in the future is refused on the read-only socket until it is deliberately classified as read-only. Membership in ezyshield-view can never mutate state — no unban, no disarm, no allowlist edits.
Both packages' postinstall create the two groups, and the daemon's unit makes the service user a member of ezyshield-view (an unprivileged process can only hand a socket to a group it belongs to — that membership is what lets the daemon group-own the read-only socket). Add a monitoring user with usermod -aG ezyshield-view <user>. The CLI falls back to the read-only socket automatically when the operator socket denies permission, so viewer-tier users run ezyshield status, list, watch, and report with no extra flags.
Every mutating verb request — refused ones included — is recorded in the append-only audit journal as a socket_cmd entry carrying the requesting peer's uid/gid (SO_PEERCRED), so "who disarmed the daemon?" is always answerable.
Data flow
Every outbound connection EzyShield can make — AI providers, Cloudflare, GeoIP updates, notifiers, ezyshield update — is opt-in and documented, with its trigger, payload, and off switch, in the Data Flow Reference. That page also gives the exact configuration for running fully local, with zero outbound traffic. There is no telemetry.
Audit trail
Every action is logged to SQLite:
- When: timestamp
- What: IP, rule, score, decision (ban/allow/defer)
- Why: rule name, AI response (if AI was consulted)
Export for compliance:
ezyshield report --json > report.json # per-IP history with evidenceCloudflare sync
When using Cloudflare Lists:
- Idempotent sync: EzyShield reconciles its view with Cloudflare at daemon startup, every 5 minutes, and whenever bans expire (adds missing entries, removes stale ones)
- Source of truth:
bans_activetable in SQLite is the source of truth. If EzyShield crashes and restarts, it will restore Cloudflare blocks from the DB. - Non-ezyshield rules preserved: EzyShield only touches its own IP list (
ezyshield_blocked) and the WAF rules it created (tagged by description). Hand-created Cloudflare rules are left alone.
Dry-run by default
armed: false is the default in policy.yaml. Enforcement is opt-in. You must explicitly set armed: true to start blocking.
Before arming, run in dry-run for 24+ hours and review decisions.
Dependencies
EzyShield ships as two static Go binaries (ezyshield + the privilege-separated ezyshield-enforcer) with minimal runtime dependencies:
- Linux kernel nftables (for local enforcement)
- Cloudflare API (optional, TLS verified)
- AI provider API (optional, TLS verified)
No Python, no Ruby, no Java runtime. No third-party packet inspection. No kernel modules.
Threat model
In scope (we protect against):
- Brute-force SSH login attempts
- WordPress/Drupal login scanners
- Port scanners and service enumeration
- HTTP bots and scrapers
Out of scope:
- Kernel exploits
- Compromised SSH keys
- Application-layer logic bugs
- Insider threats
- AI provider compromise (we assume Anthropic/OpenAI API is trustworthy)
Compliance
EzyShield maintains:
- Full audit trail (SQL queryable)
- No PII in logs (only IP addresses)
- Rate limiting to prevent denial-of-service
- Allowlist for whitelisted traffic
- Dry-run mode for testing before enforcement
This audit log data can support SOC 2, ISO 27001, and GDPR request-logging requirements — compliance itself depends on the organizational controls you build around it, not on EzyShield alone.
Reporting security issues
Found a vulnerability? Open a private security advisory on GitHub (Security tab → Report a vulnerability) — see SECURITY.md. Do not open a public issue.