Methodology
What Echo measures, where the data comes from, every rule that turns a packet into a tag, and what we deliberately exclude. The goal is for defenders to be able to evaluate our data with eyes open — not to take our word for anything.
Data source
Every event Echo surfaces comes from Sentinel, the observability stack running across the ironcat fleet (two Azure VMs). Sentinel ingests:
- SSH auth logs from each VM via Vector tailing
/var/log/auth.log. - Caddy access logs from each VM via Vector tailing the structured JSON logs Caddy writes.
A second-pass Go classifier sits in front of the database to catch attack patterns Vector's VRL doesn't easily express. Events land in Postgres tagged at the source — no batch processing, no offline reclassification.
Refresh cadences
| Layer | Refresh | Notes |
|---|---|---|
| Event tags (VRL + Go) | at ingest | Per-event classification happens before the row hits Postgres |
| Per-IP rollup (actors table) | 60 seconds | Aggregate stats + behavior tags recomputed every minute |
| Cluster detection | 60 seconds | Union-find on shared SSH usernames + shared HTTP paths |
| IP geo enrichment | async, ~30 IPs/min | Via ip-api.com; cached in ip_geo table |
| Public API caches | 60s–15min | IP lookup 60s, tag list / feeds 15min |
| Echo pages (ISR) | 60s–300s | Server-rendered with revalidation |
Behavior tags — derived from rolled-up activity
Behavior tags describe what an actor IS, computed from their cumulative behavior. Recomputed every 60 seconds; an actor whose behavior changes loses or gains tags accordingly.
| ssh.bruteforce | 10+ SSH auth failures from one IP in the rolling 30d window |
| ssh.stuffing | 5+ distinct usernames AND 10+ failures from the same IP — credential-stuffing pattern |
| probe.404flood | 20+ HTTP 4xx responses AND 15+ distinct paths — directory busting |
| actor.cross_protocol | Same IP observed on both SSH and HTTP — multi-vector reconnaissance |
| actor.persistent | Active across 24h+ with 50+ total events |
| actor.long_term | Active across 7d+ with 200+ total events — committed campaign infrastructure |
| actor.heavy | 1000+ total events from a single IP. The truly relentless. |
| actor.multi_vm | Hitting both ironcat.io AND ironcatlabs.com hosts |
| subnet.coordinated | One of ≥3 attacking IPs sharing the same /24 — almost certainly one operator on a contiguous block |
| asn.coordinated | ≥5 peers on a non-cloud-provider ASN. Excludes Google/DO/Azure/AWS/Tencent/Volcano/UCLOUD where shared tenancy is meaningless. |
| cluster.shared_wordlist | Member of a union-find component over SSH username overlap — same brute-force tooling |
| cluster.shared_paths | Member of a union-find component over HTTP probe-path overlap — same web-probe tooling |
| cluster.confirmed_operator | In BOTH a wordlist AND a paths cluster — two independent signals of shared operator |
Event tags — applied at ingest by VRL
Event tags describe what a single request did. Applied at ingest by Vector's VRL rules. An actor accumulates the union of every event tag any of their events carried.
| ssh.dictionary | SSH login attempt against a common attacker-wordlist username (root, admin, test, ubuntu, etc.) |
| ssh.enum | SSH login attempt against a non-existent system user — enumeration probe |
| probe.config | Probing for known config-file paths (.env, .git, config files) |
| probe.traversal | URI contains path traversal sequences (../, %2e%2e) |
| probe.sqli | SQL-injection patterns in URI |
| probe.xss | Cross-site-scripting patterns in URI |
| probe.log4j | JNDI lookup patterns (Log4Shell) |
| probe.shell | Shell/RCE payloads in URI |
| probe.method_abuse | Unusual HTTP methods (TRACE, OPTIONS abuse) |
| scanner.* | Matched a known scanner UA signature: nuclei, sqlmap, nikto, nmap, masscan, gobuster, ffuf, dirbuster, wpscan, etc. |
| client.threat_intel_crawler | Known legitimate research crawler (Palo Alto, RecordedFuture, Common Crawl, Censys, Shodan, BinaryEdge, LeakIX, Onyphe) |
| client.{curl,wget,headless,library,scraper,search-bot} | Client-tooling classification |
| client.{go,python,rust,node,fast}_http | HTTP library sub-classification when ascertainable |
| protocol.{old,no-accept,no-language} | HTTP-spec irregularities |
| path.{long-suspect,encoded-heavy} | URI-shape oddness |
What Echo deliberately excludes
- Successful exploitation. If a probe to
/.envreturned a 200, that's a vulnerability we haven't patched — not something we broadcast. The IP lookup endpoint filters byNOT (source = 'ssh' AND category = 'auth.success')andNOT (http 2xx AND tags LIKE 'probe.%'). - Research crawlers. Palo Alto, RecordedFuture, Common Crawl, Censys, Shodan, BinaryEdge, LeakIX, Onyphe. Tagged at ingest as
client.threat_intel_crawlerand filtered from every public feed via thefeed_exclusionstable. - Our own log shipper. Sentinel-Agent's Vector instance POSTs back to us, which Caddy logs. Self-tagged as
internal.sentinel_agentand filtered. - Echo's SSR fetches. Echo's Next.js renders by fetching from Sentinel. Filtered from the public-traffic analytics by IP.
Caveats
- Geographic data is approximate. We use ip-api.com for free, best-effort IP geolocation. A VPN or proxied attacker will appear in whichever country the proxy lives in.
- ASNs include cloud providers. A high count on Google or DigitalOcean usually means many unrelated attackers happened to rent there, not one operator. The
asn.coordinatedbehavior tag specifically excludes the cloud giants for this reason. - UA strings are spoofable.
client.*tags are heuristics; an attacker who spoofs Mozilla in their User-Agent header will look like "human" traffic until other signals catch them. - Window is rolling. All counts default to a 30-day rolling window. A probe campaign that ended 35 days ago has dropped off.
Free & open
All Echo data is published under CC0 — public domain. No attribution required. No rate-limiting. No auth. Just:
Found a methodology issue? The Sentinel source is open and the behavior rules are inspectable via the admin /tactics page on the public side.