Automating abuse reports for attacking IPs starts with a step most incident-response scripts skip: finding the network operator responsible for the address. Blocking the IP stops traffic from that address, but a well-evidenced report gives the operator a chance to investigate the system behind it and take action.
Doing that manually means looking up registration data, finding the right abuse contact, collecting timestamps and logs, and copying everything into a ticket or email. At any meaningful volume, that process gets skipped.
This guide builds a small pipeline to handle the repetitive parts: resolve the abuse contact for an offending IP, group repeated events by the abuse-handling network, and create a ticket with the contact details and evidence ready for human review. The raw lookup starts with curl, the complete workflow is implemented in Python, and the resolver is also shown in Node.js.
TL;DR
- Blocking an abusive IP stops it hitting you. Reporting it gives the responsible network operator the evidence needed to investigate the source and take action.
- The abuse contact is the operator's registered abuse desk from registry records, not the attacker. One call resolves it:
GET /v3/abuse?ip=.... - The response returns
emailsandphone_numbersas arrays, plus the operator'sname,organization,route(the CIDR they answer for),country, and postaladdress. - Dedupe on
route, not just the individual IP. Multiple abusive IPs can resolve to the same operator and abuse-handling network, so grouping them prevents your queue from filling with near-identical tickets. - Open a ticket for a human to review and send. Never auto-email abuse desks: log triggers include spoofed sources, and machine-gunning operators burns your sender reputation.
- Fail open. A lookup or ticket failure should log and move on, never block your request path.
The pipeline is three steps: resolve the abuse contact for the offending IP, dedupe by the network route so one attacker isn't hundreds of tickets, and open a ticket with the contact and evidence attached. The code below is production-shaped, not a happy-path snippet.
Abuse reputation vs operator reporting: two different jobs
People conflate two actions that do different things.
The first is reporting the IP to a community blocklist like AbuseIPDB, usually wired through Fail2Ban. That raises the IP's reputation score so other people's firewalls treat it with suspicion. Useful, but it does nothing to the source. The attacker keeps their box.
The second is telling the operator that owns the IP range. IP address allocations are registered through one of the five Regional Internet Registries (ARIN, RIPE, APNIC, LACNIC, AFRINIC), and registry records commonly include an abuse or incident-response contact for the responsible operator. That contact is a role mailbox, abuse@ by convention, defined back in RFC 2142 as the address for "inappropriate public behaviour." Send evidence there and the operator can investigate, warn, or suspend the customer running the attack.
One point the registries make and worth repeating: the abuse contact is the network operator, not the abuser. You are asking the landlord to deal with the tenant. This tutorial automates the second job. The first is a one-liner you probably already have.
What one lookup returns
You can resolve the contact from raw WHOIS or RDAP yourself, and if you enjoy parsing free-text remarks: fields across five registries with different formats, go for it.
A lookup API flattens that into consistent JSON. IPGeolocation, IPinfo, and IPLocate all expose one; ip-api does not. I'll use IPGeolocation's dedicated endpoint here because it returns the parsed abuse object in a single call and normalizes the registry differences.
Grab a key and the raw call is one line:
curl -s -X GET \
'https://api.ipgeolocation.io/v3/abuse?apiKey=API_KEY&ip=49.12.0.0'
For 49.12.0.0, an address in a Hetzner allocation, the response is:
{
"ip": "49.12.0.0",
"abuse": {
"route": "49.12.0.0/20",
"country": "DE",
"name": "Hetzner Online GmbH - Contact Role",
"organization": "ORG-HOA1-RIPE",
"kind": "group",
"address": "Hetzner Online GmbH\nIndustriestrasse 25\nD-91710 Gunzenhausen\nGermany",
"emails": [
"abuse@hetzner.com"
],
"phone_numbers": [
"+49 9831 505-3",
" +49 9831 505-0"
]
}
}
Eight fields, and each one matters when you build the ticket. route is the CIDR the desk answers for, which is your dedupe key. emails and phone_numbers are arrays, so code that assumes a string will break. kind is group or individual. organization can be an empty string on some ranges. And look at the second phone number: " +49 9831 505-0" has a stray leading space, and the address carries literal \n line breaks. Registry data is messy. Your code cleans it, or your tickets look broken.
Step 1: Resolve the abuse contact
Here's the resolver in Python. It fails open on purpose, returning None for anything it can't handle. Keep abuse reporting completely outside your application's request path. Run it from a worker, queue consumer, or detector action so an API, Redis, or ticketing outage cannot affect user traffic.
import os
import logging
import requests # pip install requests
log = logging.getLogger("abuse_pipeline")
IPGEO_API_KEY = os.environ.get("IPGEO_API_KEY")
ABUSE_URL = "https://api.ipgeolocation.io/v3/abuse"
def resolve_abuse_contact(ip):
"""Resolve the operator's abuse contact for `ip`, or None if it can't be
resolved. Fails open: reporting is a background task and must never block
the caller's request path."""
if not IPGEO_API_KEY:
log.error("IPGEO_API_KEY is not set")
return None
try:
# (connect 1s, read 1.5s). Reporting isn't latency-critical, but you
# still don't want a hung socket per detected event.
resp = requests.get(
ABUSE_URL,
params={"apiKey": IPGEO_API_KEY, "ip": ip},
timeout=(1.0, 1.5),
)
except requests.RequestException as exc:
log.warning("Abuse lookup failed for %s: %s", ip, exc)
return None
if resp.status_code == 423:
# Bogon or private IP. Nothing to report, and your detector probably
# shouldn't have flagged an RFC 1918 address in the first place.
log.info("Skipping bogon/private IP %s", ip)
return None
if resp.status_code == 401:
log.error("Abuse endpoint returned 401; check the API key.")
return None
if resp.status_code == 429:
log.warning("Rate limited on abuse lookups; back off and retry later")
return None
if resp.status_code != 200:
log.warning("Abuse lookup for %s returned HTTP %s", ip, resp.status_code)
return None
try:
payload = resp.json()
except ValueError as exc:
log.warning("Invalid JSON from abuse lookup for %s: %s", ip, exc)
return None
abuse = (payload or {}).get("abuse") or {}
emails = abuse.get("emails") or []
if not emails:
# No registered abuse address. Rare, but real on some ranges.
log.info("No abuse email for %s (%s)", ip, abuse.get("route"))
return None
return {
"route": abuse.get("route"),
"country": abuse.get("country"),
"org": abuse.get("organization") or abuse.get("name") or "unknown operator",
"emails": emails,
"phones": [p.strip() for p in (abuse.get("phone_numbers") or [])],
"address": (abuse.get("address") or "").replace("\n", ", "),
}
The status-code handling isn't padding. 423 is a bogon or private IP, 401 is a free key hitting a paid endpoint, 429 is your rate limit. Each one is a different decision, and collapsing them into a bare except hides the one you actually need to act on. The .strip() on phones and the newline replace on the address are there because of the exact mess you saw in the payload above.
The same thing in Node. Node 18+ has global fetch, so no dependency:
const IPGEO_API_KEY = process.env.IPGEO_API_KEY;
const ABUSE_URL = "https://api.ipgeolocation.io/v3/abuse";
async function resolveAbuseContact(ip) {
// Fails open: returns null on any problem so the caller keeps running.
if (!IPGEO_API_KEY) {
console.error("IPGEO_API_KEY is not set");
return null;
}
const url = `${ABUSE_URL}?apiKey=${IPGEO_API_KEY}&ip=${encodeURIComponent(ip)}`;
let resp;
try {
// Abort after 1.5s so a slow lookup never wedges the event loop.
resp = await fetch(url, { signal: AbortSignal.timeout(1500) });
} catch (err) {
console.warn(`Abuse lookup failed for ${ip}: ${err.message}`);
return null;
}
if (resp.status === 423) return null; // bogon / private IP
if (resp.status === 401) {
console.error("Abuse endpoint returned 401; check the API key.");
return null;
}
if (resp.status === 429) {
console.warn("Rate limited on abuse lookups; back off and retry later");
return null;
}
if (!resp.ok) {
console.warn(`Abuse lookup for ${ip} returned HTTP ${resp.status}`);
return null;
}
let payload;
try {
payload = await resp.json();
} catch (err) {
console.warn(`Invalid JSON from abuse lookup for ${ip}: ${err.message}`);
return null;
}
const abuse = payload?.abuse ?? {};
const emails = abuse.emails ?? [];
if (emails.length === 0) return null; // no registered abuse desk
return {
route: abuse.route,
country: abuse.country,
org: abuse.organization || abuse.name || "unknown operator",
emails,
phones: (abuse.phone_numbers ?? []).map((p) => p.trim()),
address: (abuse.address ?? "").replace(/\n/g, ", "),
};
}
Both return a small normalized object or nothing. Everything downstream can trust the shape.
Step 2: Dedupe by route, not by IP
This is the step every other guide misses, and it's the one that decides whether your queue stays usable.
A single attacker sitting on a hosting provider rarely stays on one IP. They rotate across the block. If you dedupe on the IP, a scanner walking 49.12.0.5, 49.12.0.6, 49.12.0.7 opens a fresh ticket every hop, all pointing at the same Hetzner abuse desk. The desk gets 200 near-identical emails, ignores all of them, and your queue is noise.
Dedupe on route instead. The abuse contact answers for the whole CIDR, so one ticket per route per window is the right resolution. Redis makes it atomic:
import redis # pip install redis
rdb = redis.Redis(host="localhost", port=6379, db=0)
def claim_route(route, ttl_seconds=86400):
"""True the first time a route is seen in the TTL window, False after.
SET NX is atomic, so two workers racing the same route still open one
ticket, not two."""
if not route:
return False
try:
return bool(rdb.set(f"abuse:route:{route}", "1", nx=True, ex=ttl_seconds))
except redis.RedisError as exc:
# Dedupe store is down. Allow the ticket rather than silently dropping
# reports; a rare duplicate beats a missed abuse report.
log.warning("Dedupe unavailable (%s); allowing ticket for %s", exc, route)
return True
A one-day window is a reasonable default. A persistent attacker is worth one ticket a day, not one a minute. If you're single-process and don't want Redis, a dict of route -> expiry works, but you lose atomicity the moment you scale past one worker.
Step 3: Open the ticket
Now the resolved contact becomes a ticket. Here's a generic create_ticket() with Jira Cloud as the concrete target. The same normalized contact and evidence can be sent to Zendesk, ServiceNow, Linear, or another tracker, but each system has its own endpoint, authentication model, and ticket fields. Jira Cloud is the concrete example below.
import os
import requests
JIRA_BASE = os.environ.get("JIRA_BASE_URL") # https://you.atlassian.net
JIRA_EMAIL = os.environ.get("JIRA_EMAIL")
JIRA_TOKEN = os.environ.get("JIRA_API_TOKEN") # id.atlassian.com API token
JIRA_PROJECT = os.environ.get("JIRA_PROJECT_KEY", "SEC")
def create_ticket(summary, body):
"""Create a ticket for a human to review before anything reaches the
operator. Jira Cloud here; other trackers differ only in fields and auth."""
if not (JIRA_BASE and JIRA_EMAIL and JIRA_TOKEN):
log.error("Jira credentials are not fully configured")
return None
payload = {
"fields": {
"project": {"key": JIRA_PROJECT},
"summary": summary[:255], # Jira caps summaries at 255
"issuetype": {"name": "Task"},
"description": {
"type": "doc", "version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": body}],
}],
},
}
}
try:
resp = requests.post(
f"{JIRA_BASE}/rest/api/3/issue",
auth=(JIRA_EMAIL, JIRA_TOKEN),
json=payload,
timeout=(1.0, 3.0),
)
resp.raise_for_status()
except requests.RequestException as exc:
log.error("Ticket creation failed: %s", exc)
return None
return resp.json().get("key") # e.g. "SEC-1421"
The API token lives in an env var, never in the file. The [:255] truncation is a real Jira limit that will 400 your request if you ignore it. Everything is wrapped, because a tracker outage should log and drop the report, not crash the process handling your attack traffic.
The glue ties the three steps together:
from datetime import datetime, timezone
def report_abusive_ip(ip, reason, log_excerpt):
contact = resolve_abuse_contact(ip)
if not contact:
return # failed open, already logged
if not claim_route(contact["route"]):
log.info("Already ticketed %s today; skipping %s",
contact["route"], ip)
return
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%SZ")
summary = f"Abuse report: {ip} ({contact['route']}) via {contact['org']}"
body = (
f"Offending IP: {ip}\n"
f"Network: {contact['route']} ({contact['org']}, {contact['country']})\n"
f"Reason: {reason}\n"
f"Detected at (UTC): {ts}\n\n"
f"Send report to: {', '.join(contact['emails'])}\n"
f"Phone (incident only): {', '.join(contact['phones']) or 'none registered'}\n"
f"Postal: {contact['address'] or 'n/a'}\n\n"
f"Evidence:\n{log_excerpt}"
)
key = create_ticket(summary, body)
if key:
log.info("Opened %s for %s", key, ip)
The ticket carries what an abuse desk needs to act: the offending IP, the network, a reason, a UTC timestamp, and the log excerpt as evidence. It also carries the resolved emails so whoever picks up the ticket sends to the right desk without re-doing the lookup. UTC matters; a report timestamped in your local zone forces the operator to guess, and abuse desks that get vague reports close them.
Wiring it to a trigger
The pipeline needs an offending IP from somewhere. Your detection layer already produces them: Fail2Ban jails, a rate-limit rule, a WAF event, an SSH log parser. The cleanest hook is a small entrypoint your detector calls:
import sys
if __name__ == "__main__":
# Fail2Ban action example:
# actionban = /usr/bin/python3 /opt/abuse/report.py <ip> "ssh brute force"
if len(sys.argv) < 2:
sys.exit("usage: report.py <ip> [reason]")
offending_ip = sys.argv[1]
reason = sys.argv[2] if len(sys.argv) > 2 else "automated detection"
report_abusive_ip(offending_ip, reason, log_excerpt="see host logs")
One caveat that will bite you if you pull the IP from a web request instead of a firewall log: make sure it's the real source. Behind a proxy or load balancer, the socket address is the proxy, and X-Forwarded-For is caller-controlled and trivially spoofed. Report the wrong hop and you're emailing an innocent operator. Only trust X-Forwarded-For from proxies you actually run, and take the right entry, not the first thing an attacker put there.
The one thing not to automate
Resolve, dedupe, ticket. Do not add a fourth step that emails the abuse desk automatically. I'd argue this is the line, and it's worth being blunt about.
Detection triggers fire on spoofed and forged sources all the time. A NANOG thread on exactly this problem put it plainly years ago: the hard part of automated reporting was "dealing with spoofed sources," traffic that looks hostile but didn't come from where it claims. Wire an auto-emailer to a raw log trigger and you will send reports about IPs that never touched you, to desks that will start filtering your domain. Once abuse@ stops reading your mail, your real reports go nowhere.
A ticket puts a human in the loop for ten seconds: glance at the evidence, confirm it's real, send. That's the difference between a report an operator acts on and a report that gets your sender reputation torched. The automation should do the tedious part, the lookup and the routing, and stop at the judgment call.
When you outgrow the API
Per-request lookups are right until they aren't. If you're processing millions of events and calling the endpoint on each one, you're paying a credit and a round trip per event (the X-Credits-Charged response header tells you the exact cost). At that volume, the downloadable abuse contact database is the move: match the IP against a local copy refreshed daily, no network call in the hot path. Same data, sourced from all five RIRs, without the per-lookup cost.
And read the operator's terms before you scale up reporting. A polite, evidenced, deduplicated report is welcome. A firehose is abuse of the abuse desk, which is a special kind of irony you don't want to be responsible for.
Top comments (0)