Skip to content

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:

  1. Allowlist check: the target IP is matched against the static allowlist — your configured allowlist, admin_cidrs from policy.yaml, and the SSH peer captured when the daemon started.
  2. Live SSH re-check: the target IP is matched against the SSH peers active right now — from the kernel's connection table (/proc/net/tcp and /proc/net/tcp6, remote ends of established connections to the sshd port(s) — works under systemd, no environment needed; sshd ports read from sshd_config, fallback 22) and, in interactive contexts, from the current SSH_CLIENT env 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.

yaml
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 ban

Rate 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_NAME syntax)
  • 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):

  1. Schema validation: AI output is parsed into a structured type; malformed responses cause a fallback decision.
  2. Policy clamping: AI can only suggest within the ban thresholds and durations you configured. It cannot escalate beyond them.
  3. 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.
  4. Audit trail: every AI verdict (source, score, reason) is persisted with the strike, so you can audit and override if needed.
  5. 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): holds CAP_NET_ADMIN only, 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:

  1. A host log file. The container writes its access log to a bind-mounted host path, and a kind: file collector reads it. EzyShield gets no Docker access at all. Reach for this first.

  2. 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. Point docker.host at it:

    yaml
    docker:
      host: tcp://127.0.0.1:2375

    The daemon speaks the Engine API over loopback TCP and stays out of the docker group. 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.

  3. The docker group. Root-equivalent, as above. Only when neither of the other two fits.

How the choice is made:

  • ezyshield init offers 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 key collectors.docker_host) or --docker-group (answers key collectors.docker_group: true). Passing both is an error — the proxy replaces the group, it does not accompany it. --yes accepts safe defaults, and neither grant is a default.
  • Choosing nothing still writes the collectors — they simply cannot read container logs until access exists.
  • ezyshield doctor warns whenever the service user is in docker, and says whether anything in your configuration justifies it. When docker.host is a TCP endpoint, doctor also probes it: it must answer GET /_ping and it must refuse POST /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:

bash
getent group docker                          # is ezyshield listed?
sudo gpasswd -d ezyshield docker             # revoke
sudo systemctl restart ezyshield             # drop the group from the running daemon

Revoking 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:

TierSocketGroupVerbs
Operatorezyshield.sockezyshieldeverything: read verbs plus ban, unban, allow, unallow, arm, disarm, disable_all, prune, feeds_refresh
Viewerezyshield-ro.sockezyshield-viewread 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:

bash
ezyshield report --json > report.json   # per-IP history with evidence

Cloudflare sync ​

When using Cloudflare Lists:

  1. Idempotent sync: EzyShield reconciles its view with Cloudflare at daemon startup, every 5 minutes, and whenever bans expire (adds missing entries, removes stale ones)
  2. Source of truth: bans_active table in SQLite is the source of truth. If EzyShield crashes and restarts, it will restore Cloudflare blocks from the DB.
  3. 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.