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.
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.
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
The enforcer is not a library. It's a separate process. The main daemon cannot directly modify the firewall.
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)
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.