diff options
Diffstat (limited to 'docs/specs/2026-09-09-contacts.md')
| -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. |
