diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-09 08:56:25 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-09 08:56:25 +0200 |
| commit | 42a36e88424aa3dac13ed3d578bfa871e44ad460 (patch) | |
| tree | badad98b9b5e2c069ba01a41189a0b8f56233e2d /docs/specs | |
| parent | 00c68e841c416c7c069869a52d64e8c15a72e1de (diff) | |
| download | abusectl-42a36e88424aa3dac13ed3d578bfa871e44ad460.tar.gz abusectl-42a36e88424aa3dac13ed3d578bfa871e44ad460.zip | |
docs: spec the contacts subcommand
Settles what the umbrella design left open for this part: RDAP bootstrap,
caching policy, and what happens for a netblock that publishes no abuse
contact.
Scope is IPs and domains, with a URL contributing only its host. The
bootstrap files are fetched from IANA and cached with a long TTL, while
responses are cached in memory for one run only: a stale bootstrap merely
misses, but a stale abuse address sends a report into a dead mailbox
silently. The registrable domain is found by walking up the labels and
asking the registry rather than by bundling a Public Suffix List, which
would be a transcribed table that goes stale weekly.
Address extraction is a strict abuse-role match with no fallback. In
particular it never constructs abuse@<domain>, because for a phishing
domain that mailbox belongs to the attacker and mailing it would confirm
both the catch and that the address is live.
Adds a fourth non-negotiable property: a query carries a bare host or IP
and never a URL. Property 1 governs what is published, and a query is a
disclosure that appears in no report, so a path segment that parse
deliberately flags rather than redacts would otherwise leak.
Transport is urllib with an injected fetch, so requirements.txt stays
empty and the suite keeps passing with sockets raising, for the whole
suite rather than for everything except contacts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
Diffstat (limited to 'docs/specs')
| -rw-r--r-- | docs/specs/2026-09-09-contacts.md | 345 |
1 files changed, 345 insertions, 0 deletions
diff --git a/docs/specs/2026-09-09-contacts.md b/docs/specs/2026-09-09-contacts.md new file mode 100644 index 0000000..faeef58 --- /dev/null +++ b/docs/specs/2026-09-09-contacts.md @@ -0,0 +1,345 @@ +# abusectl `contacts`: IOCs to abuse contacts, over RDAP + +Status: **agreed 2026-09-09**, in one brainstorming session with the user. +This spec settles the part the umbrella design left open, named there as +"RDAP bootstrap and referral chasing, caching policy, rate limits, and what +happens for a netblock that publishes no abuse contact". + +Read `docs/specs/2026-09-08-abusectl-design.md` first. This document assumes +its manifest format, its case directory and its ordering between the parts, +and changes none of them. + +## What it does + +``` +abusectl contacts <case> +``` + +Reads a case manifest, resolves an abuse contact for every IP and domain +indicator via RDAP, and rewrites the manifest with a `contacts[]` array. It +is read-only against the network: it queries registries and sends no mail, +files no report and changes nothing a third party can observe beyond the +queries themselves. + +It is the FIRST network module in this tool. Everything below that looks +defensive is defensive because of that. + +## Modules + +``` +abusectl/ + contacts.py IOCs -> contacts[] network, read-only + rdap.py bootstrap, query, jCard network, read-only +``` + +`rdap.py` is the PROTOCOL: fetching and caching the IANA bootstrap files, +selecting a server for an address or a TLD, issuing a query, and pulling an +abuse address out of a jCard. `contacts.py` is the POLICY: which indicators +are resolvable, how hosts fold together, what is written into the manifest. + +**The split is the same one that puts `redact.py` beside `parse.py`.** The +protocol dirt is where the defects will be, and a module of its own gets +tests that name it rather than tests that reach it incidentally through case +handling. + +### The offline guarantee survives, for the whole suite + +Both modules take a `fetch` callable as an ARGUMENT, defaulting to the real +urllib transport. Tests pass a fake and never construct the real one. + +The suite must keep passing with `socket.socket`, `socket.create_connection` +and `socket.getaddrinfo` all raising, exactly as it does today, and that +property stays a property of the WHOLE suite. Not "the whole suite except +contacts". A network module that can only be tested with a network is a +module that stops being tested. + +This is the pattern the umbrella design promised for exactly this moment: if +a later part needs the network, it goes in its own module with an injected +transport. + +## THE FOURTH PROPERTY + +The umbrella design states three properties that are not negotiable. This +part adds a fourth, because it is the first part that can violate it. + +### Never disclose more than the host under investigation + +An RDAP query tells a third party what the user is looking at. Queries carry +BARE HOSTS AND IP ADDRESSES ONLY, never a full URL. + +A URL path can carry recipient identity. `parse.suspect_path_segments()` +FLAGS those rather than redacting them, deliberately, because a path segment +may be the thing being reported. That decision is safe only while the URL +stays local. Sending a URL to a registry would leak precisely what property 1 +exists to prevent, through a channel property 1 does not cover: property 1 +governs what is PUBLISHED, and a query is a disclosure that never appears in +any report. + +Concretely: a `url` indicator contributes its HOST to the domain worklist and +nothing else. Path, query and fragment never leave the machine. + +**This is a trap, not a theoretical concern.** The obvious implementation +resolves "a contact for each indicator" by reading each indicator's `value`, +and for a `url` indicator that value is an entire URL. The sweep described +under Testing exists to catch exactly that mistake against real mail. + +## What gets resolved + +| Indicator | Query | Why | +|---|---|---| +| `ipv4`, `ipv6` | RDAP IP lookup | the sending IP is the primary takedown target | +| `domain` | RDAP domain lookup | the registrar holds the abuse desk | +| `url` | its HOST, as a domain | folded into the domain worklist, see the fourth property | +| `sha256` | none | no registry owns a hash | +| `observation` | none | not a network object | + +Indicators carrying `confidence: untrusted-hop` ARE resolved. A forged chain's +IP may still be the real sender's, and refusing to resolve it would discard +the case the tool exists for. The confidence marker stays in the manifest, so +review shows the user that a contact came from an attacker-supplied hop +before any report goes out. + +**Hosts fold.** Twenty URLs on one host produce one worklist entry and one +query. The resulting contact entry lists every indicator id that contributed, +so nothing is lost by folding. + +## RDAP mechanics + +### Bootstrap + +Fetch `ipv4.json`, `ipv6.json` and `dns.json` from `data.iana.org/rdap/`. +Cache them under `$XDG_CACHE_HOME/abusectl/rdap/`, defaulting to +`~/.cache/abusectl/rdap/`. TTL 7 days. + +The cache is NOT in the case directory. It is not evidence, it is a copy of a +public map. + +**A failed refetch falls back to the stale copy**, with a warning, rather +than failing the run. Last week's map is almost certainly still correct, and +IANA being unreachable should not stop the user filing a report. A stale +bootstrap is safe to serve: the worst case is querying a server that has +moved, which fails and reads as "no contact". + +### Server selection + +- IP: longest-prefix match over the bootstrap ranges, using `ipaddress`. +- Domain: exact TLD match in `dns.json`. +- No match: `abuse: []` with the reason recorded. Many TLDs publish no RDAP + service at all, and that is a normal outcome rather than a defect. + +### The registrable domain, by walking up + +RDAP domain lookup wants the registrable domain. `mail.deep.example.invalid` +is not registrable and most registries answer it with a 404. + +Query the full host, then drop one label and retry, until a server answers or +the labels run out. Cap at 5 attempts and NEVER query a bare TLD. + +**This replaces a Public Suffix List deliberately.** Taking the last two +labels is wrong for every multi-part suffix, `co.uk` and `com.au` among them, +and a bundled PSL snapshot is a transcribed table that goes stale weekly, +which is the failure `init.PROVIDERS` already documents at length. The +registry is the authority on what is registrable, so the walk asks it instead +of approximating it. + +The in-memory dedup below makes the walk cheap in the common case: a +campaign's twenty subdomains under one registrable domain collapse to one +successful query plus a few cheap misses. + +### Transport + +`urllib.request` from the standard library. `requirements.txt` stays empty. + +- `Accept: application/rdap+json` +- **timeout mandatory**, 10 seconds. `urllib` with no timeout blocks forever, + and a hung registry would hang a review. +- **redirects capped at 5 hops.** RIRs redirect to each other and the default + handler follows without a limit of ours. +- **an https to http downgrade is refused.** + +The cap and the downgrade refusal follow the discipline `_MAX_REDIRECT_DEPTH` +already sets in `parse.py`, and for the same reason: this is remote data +directing our next move. + +`requests` was considered and rejected. It is present in slackware64-current, +so packaging was not the deciding factor. It simply buys little here: the two +things this module needs beyond a plain GET are the redirect cap and the +downgrade refusal, and neither is a knob on `requests` either, both needing a +`Session` plus custom wiring of comparable size. An empty `requirements.txt` +means the tool runs and tests with no venv step at all. + +### Caching policy + +Bootstrap: on disk, 7 day TTL, as above. + +Responses: **in memory only, keyed by query URL, for the duration of one +run.** Nothing on disk. + +The asymmetry is deliberate and is about which staleness hurts. A stale +bootstrap fails loudly enough, producing a miss. **A stale abuse address +sends a report into a mailbox nobody reads, silently**, and the user learns +nothing came of it only by never hearing back. RDAP rate limits are generous +for a human-paced tool filing a handful of reports, so the saving is not +worth that. + +In-run dedup is free and still worth having: it is what stops a case with +forty URLs on one host from issuing forty identical queries. + +## Extracting the address + +Strict role match, and nothing else. An entity whose `roles` contains `abuse` +yields the `email` entries of its jCard. Recurse into nested `entities`, +depth capped at 4, because the abuse entity is usually a child of the +organisation entity. + +Four rules, each with a victim: + +**1. No fallback to a non-abuse entity.** A `technical` or `registrant` +contact is a named human who never volunteered to receive abuse mail. Mailing +them is useless and is a small privacy harm to an uninvolved third party. + +**2. No `abuse@<domain>` by convention.** RFC 2142 says the mailbox should +exist; for a phishing domain it belongs to the ATTACKER. Constructing it +would mail the attacker to tell them their campaign was caught and that the +user's address is live, which is the exact outcome property 2 exists to +prevent. This is the single worst thing this module could do, and it looks +like a helpful fallback, which is why it is written down. + +**3. Never follow a `links` referral to fetch a contact.** Some responses +point elsewhere for an entity. Following that is an outbound fetch to a URL +the response chose for us, which is SSRF-shaped. If the address is not in the +response, there is no address. + +**4. Validate the address before storing it.** `email.utils.parseaddr`, +exactly one address, and reject any control character or newline. It becomes +a mail recipient in `report` and `submit`; a CRLF there is header injection +into mail this tool sends. + +**Multiple abuse addresses are all kept.** Some netblocks publish two, and +picking one arbitrarily can drop the desk that would have answered. + +**A missing contact is a normal outcome, not a failure.** The umbrella design +already settles this: the indicator keeps an empty `abuse` with the reason, +no email destination is created for it, and review shows what could not be +resolved. The indicator still reaches MISP and the vendor feeds. + +## The manifest + +```json +"contacts": [ + { "iocs": ["ioc-1"], "query": "198.51.100.7", + "abuse": ["abuse@example.invalid"], + "source": "rdap", "handle": "AS64496", + "server": "rdap.example.invalid" }, + { "iocs": ["ioc-3", "ioc-7"], "query": "example.invalid", + "abuse": [], "source": "rdap", + "error": "no abuse role published" } +] +``` + +Two departures from the sketch in the umbrella design, both deliberate: + +- **`iocs` is a list**, because hosts fold and one contact can serve several + indicators. +- **`abuse` is a list**, because multiple desks are real. + +`query` records what was actually asked. Review can then see that a URL +indicator was resolved by its host, which is the fourth property made visible +rather than merely promised. + +`case.py` remains the only writer, and the write stays atomic. + +### Re-running + +**A re-run overwrites `contacts[]` wholesale**, in a single atomic write +after every lookup has finished. + +Never a merge. A merge lets a contact resolved a week ago survive into a +report filed today, which is the stale-address hazard the response caching +policy already refuses. Overwriting makes a re-run always safe and always +current, which matters because a partial network failure makes re-running the +natural next step. + +### Failure is per query + +A timeout or an error on one indicator records that reason against that entry +and the run continues. The manifest is written once at the end regardless. A +run that resolved nothing at all still writes, so that review shows why +rather than showing an absent section. + +## Testing + +TDD, as everywhere in this repository. Write the failing test, watch it fail, +implement, watch it pass. + +`rdap.py` is tested with a fake `fetch` returning canned responses in real RIR +SHAPES, with every address, handle and range replaced by `example.invalid` +and RFC 5737 documentation values, per the fixture rule in AGENTS.md. + +Tests that have a right answer: + +- longest-prefix server selection, including an address matching two ranges +- the label walk stops at the first server that answers, and never queries a + bare TLD +- nested entity recursion finds an abuse entity under an organisation +- a response carrying only a `technical` role yields no address, not the + technical one +- a `links` referral is NOT fetched: the fake transport asserts it was never + called for that URL +- a CRLF in an email field is rejected +- **a `url` indicator contributes only its host**: the fake transport asserts + no query ever contained the path +- a stale bootstrap is used when the refetch fails +- the whole suite still passes with sockets raising + +### Two sweeps of the user's real spam + +The `parse` sweep found a header the parser never read and a spoofed +`Reply-To` no fixture had. The same corpus is worth sweeping here, but this +module sends traffic, so it splits in two. + +**Sweep A, offline, the whole corpus.** Build the worklist from all messages +with a fake transport that RECORDS queries and returns canned responses. No +packets. This carries the assertion: + +> no query string ever contained anything but a bare host or IP address + +That is the fourth property checked against real mail rather than against +fixtures, over the shapes a synthetic fixture misses: a URL with a path, with +userinfo, an IDN, a trailing dot, a bracketed IPv6 literal, a host that is +itself an address. It also yields free counts: unique hosts, how many +indicators fold per host, how deep the label walks go. + +**Sweep B, online, a deliberate handful.** Ten to twenty indicators picked by +hand across distinct netblocks and TLDs, against the real registries. This +confirms that real jCard shapes parse, that the walk terminates, and that the +selection works. Twenty queries is polite and unremarkable. + +Sweep A carries the assertion. Sweep B is a shape check. + +**Ask before either, and sweep B needs an explicit go-ahead**, because unlike +every test before it in this repository it is OUTWARD-FACING: it discloses to +registries, and to a registrar who may be the attacker's, which netblocks and +domains the user is investigating, from their address, at a known time. That +cannot be undone, which is why it stays small and hand-picked rather than +being run over the corpus. + +What may leave the sweep scripts: counts, tallies, TLDs, error reasons, walk +depths, and whether any query was ever malformed. What may not: an address, a +real abuse contact, a real domain written into a test, or a URL from a real +message. A defect found by a sweep is reproduced as a synthetic fixture and +THAT is committed. The real messages stay in the scratchpad, which is +per-session and outside the repository. + +## Deliberately absent + +- **No response cache on disk.** See the caching policy above. +- **No PSL.** See the label walk above. +- **No retry or backoff here.** Rate-limit handling belongs to `submit` and + `retry`, which own the deadline logic the umbrella design specifies. A + read-only lookup that fails is simply re-run. +- **No `whois` fallback.** It is a different protocol with an unparseable + free-text response per registry, and its absence costs a contact rather + than a report. +- **No contact for `sha256` or `observation` indicators.** No registry owns + them. |
