aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/plans/2026-09-09-contacts.md1985
1 files changed, 1985 insertions, 0 deletions
diff --git a/docs/plans/2026-09-09-contacts.md b/docs/plans/2026-09-09-contacts.md
new file mode 100644
index 0000000..40392d8
--- /dev/null
+++ b/docs/plans/2026-09-09-contacts.md
@@ -0,0 +1,1985 @@
+# abusectl `contacts` Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build `abusectl contacts <case>`, which resolves an abuse contact for every IP and domain indicator in a case manifest via RDAP and rewrites the manifest with a `contacts[]` array.
+
+**Architecture:** Two modules. `rdap.py` is the protocol (IANA bootstrap fetch and cache, server selection, query, jCard extraction). `contacts.py` is the policy (which indicators resolve, how hosts fold, what reaches the manifest). Both take a `fetch` callable as an argument, defaulting to a real urllib transport, so the entire test suite keeps passing with sockets raising.
+
+**Tech Stack:** Python 3.11+ standard library only. `urllib.request`, `ipaddress`, `json`, `email.utils`. No new dependencies; `requirements.txt` stays empty.
+
+**Read first:** `docs/specs/2026-09-09-contacts.md`. It states a FOURTH non-negotiable property, that a query carries a bare host or IP and never a URL, and every task below that touches a query exists to hold that property.
+
+---
+
+## Background for an engineer new to this codebase
+
+**RDAP** is the JSON successor to `whois`. You ask a registry about an IP or a
+domain and get JSON back. Which registry to ask is answered by three bootstrap
+files IANA publishes, mapping IP ranges and TLDs to server base URLs.
+
+**jCard** (RFC 7095) is how RDAP encodes contact details: vCard as nested JSON
+arrays rather than objects. An entity looks like this:
+
+```json
+{
+ "roles": ["abuse"],
+ "vcardArray": ["vcard", [
+ ["version", {}, "text", "4.0"],
+ ["fn", {}, "text", "Abuse Desk"],
+ ["email", {}, "text", "abuse@example.invalid"]
+ ]]
+}
+```
+
+Note the shape: `vcardArray[1]` is a list of property arrays, each
+`[name, params, type, value]`. The value is at index 3.
+
+**Existing modules you will use:**
+
+- `case.load(path) -> dict` reads a manifest, `case.save(path, manifest)`
+ writes it atomically. `case.py` is the only writer of a case directory.
+- `config.load(path) -> Config` with `.cases` and `.trusted_relays`.
+- `cli.py` dispatches subcommands and owns exit codes: `EXIT_OK = 0`,
+ `EXIT_ERROR = 1`, `EXIT_NOT_CONFIGURED = 3`.
+
+**Indicators in a manifest** look like `{"id": "ioc-1", "type": "ipv4",
+"value": "198.51.100.7", "origin": "received-chain"}`. Types in play:
+`ipv4`, `ipv6`, `domain`, `url`, `sha256`, `observation`.
+
+**Test conventions:** `python3 -m unittest discover tests`. Standard library
+`unittest`, no pytest, no fixtures directory beyond `tests/fixtures/*.eml`.
+Fixtures use `example.invalid`, `.invalid` and RFC 5737 documentation ranges
+(`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`) only. Never a real
+domain, address or netblock.
+
+**Commits are GPG-signed:** `git commit -S`. Never pass `--no-verify`.
+
+---
+
+## File structure
+
+| File | Responsibility |
+|---|---|
+| Create: `abusectl/rdap.py` | Bootstrap cache, server selection, query, jCard extraction |
+| Create: `abusectl/contacts.py` | Worklist from indicators, fold hosts, build `contacts[]` |
+| Create: `tests/test_rdap.py` | Protocol tests, fake transport |
+| Create: `tests/test_contacts.py` | Policy tests, including the fourth property |
+| Modify: `abusectl/cli.py` | Add the `contacts` subparser and `_cmd_contacts` |
+| Modify: `tests/test_cli.py` | Dispatch test for the new subcommand |
+
+Task order builds bottom-up: transport, then bootstrap, then selection, then
+extraction, then policy, then the command line. Every task ends green and
+committed.
+
+---
+
+### Task 1: The transport
+
+**Files:**
+- Create: `abusectl/rdap.py`
+- Test: `tests/test_rdap.py`
+
+The transport is the only code in this repository that opens a socket. It is
+a separate function taking no case state so that everything above it can be
+tested with a fake.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/test_rdap.py`:
+
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Tests for the RDAP protocol module."""
+
+import email
+import unittest
+import urllib.error
+import urllib.request
+
+from abusectl import rdap
+
+
+class RedirectPolicy(unittest.TestCase):
+ """A redirect is remote data directing our next request.
+
+ urllib.request.Request is used rather than a hand-rolled fake, because
+ HTTPRedirectHandler reads attributes (origin_req_host, unverifiable,
+ timeout) that a fake would have to reproduce exactly to prove anything.
+ """
+
+ def _request(self):
+ return urllib.request.Request("https://rdap.example.invalid/ip/192.0.2.1")
+
+ def test_an_https_to_http_downgrade_is_refused(self):
+ handler = rdap._NoDowngradeRedirectHandler()
+ with self.assertRaises(urllib.error.HTTPError):
+ handler.redirect_request(
+ self._request(), None, 302, "Found",
+ email.message_from_string(""),
+ "http://rdap.example.invalid/ip/192.0.2.1",
+ )
+
+ def test_an_https_to_https_redirect_is_allowed(self):
+ handler = rdap._NoDowngradeRedirectHandler()
+ result = handler.redirect_request(
+ self._request(), None, 302, "Found",
+ email.message_from_string(""),
+ "https://other.example.invalid/ip/192.0.2.1",
+ )
+ self.assertIsNotNone(result)
+
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_rdap -v`
+Expected: FAIL, `ModuleNotFoundError: No module named 'abusectl.rdap'`
+
+- [ ] **Step 3: Write the module and the transport**
+
+Create `abusectl/rdap.py`:
+
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""RDAP: which registry to ask, how to ask it, and how to read the answer.
+
+This is the first module in abusectl that opens a socket. Everything that
+looks defensive here is defensive because of that.
+
+The network entry point is a single `fetch` callable, passed as an argument
+everywhere above it and defaulting to `http_fetch` below. Tests pass a fake
+and never construct the real one, which is what keeps the whole suite
+passing with socket.socket, socket.create_connection and socket.getaddrinfo
+all raising. A network module that can only be tested with a network is a
+module that stops being tested.
+
+Redirects are capped and an https to http downgrade is refused, because a
+redirect is remote data directing our next request. This follows the
+discipline _MAX_REDIRECT_DEPTH already sets in parse.py.
+"""
+
+import json
+import urllib.error
+import urllib.request
+
+_TIMEOUT = 10
+_MAX_REDIRECTS = 5
+_ACCEPT = "application/rdap+json, application/json;q=0.9"
+
+
+class _NoDowngradeRedirectHandler(urllib.request.HTTPRedirectHandler):
+ """Refuse a redirect that drops from https to http.
+
+ urllib follows redirects by default and will happily downgrade. A
+ downgraded RDAP query travels in clear text, disclosing which netblock
+ the user is investigating to anyone on the path.
+ """
+
+ max_redirections = _MAX_REDIRECTS
+
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ if req.get_full_url().startswith("https://") and newurl.startswith("http://"):
+ raise urllib.error.HTTPError(
+ newurl, code,
+ "refusing an https to http redirect",
+ headers, fp,
+ )
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+_opener = urllib.request.build_opener(_NoDowngradeRedirectHandler())
+
+
+def http_fetch(url: str) -> dict:
+ """GET a URL and parse the JSON body. The only socket in this tool.
+
+ A timeout is mandatory rather than defaulted: urllib with no timeout
+ blocks forever, and a hung registry would hang a review.
+ """
+ request = urllib.request.Request(url, headers={"Accept": _ACCEPT})
+ with _opener.open(request, timeout=_TIMEOUT) as response:
+ return json.loads(response.read())
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_rdap -v`
+Expected: PASS, 2 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/rdap.py tests/test_rdap.py
+git commit -S -m "feat: add the RDAP transport, with a redirect cap and no downgrade
+
+The only socket in this tool. A redirect is remote data directing our
+next request, so hops are capped and an https to http downgrade is
+refused: a downgraded query travels in clear text and discloses which
+netblock is under investigation to anyone on the path.
+
+The timeout is mandatory rather than defaulted, because urllib with no
+timeout blocks forever and a hung registry would hang a review."
+```
+
+---
+
+### Task 2: Bootstrap cache
+
+**Files:**
+- Modify: `abusectl/rdap.py`
+- Test: `tests/test_rdap.py`
+
+IANA publishes `ipv4.json`, `ipv6.json` and `dns.json`. Cache them under
+`$XDG_CACHE_HOME/abusectl/rdap/`, TTL 7 days, and fall back to a stale copy
+when a refetch fails.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/test_rdap.py`, before the `if __name__` block:
+
+```python
+import json
+import tempfile
+import time
+from pathlib import Path
+
+
+class Bootstrap(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.cache = Path(self.tmp.name)
+ self.addCleanup(self.tmp.cleanup)
+
+ def test_a_missing_file_is_fetched_and_cached(self):
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ return {"services": []}
+
+ data = rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch)
+
+ self.assertEqual(data, {"services": []})
+ self.assertEqual(calls, ["https://data.iana.org/rdap/ipv4.json"])
+ self.assertTrue((self.cache / "ipv4.json").exists())
+
+ def test_a_fresh_cache_is_not_refetched(self):
+ (self.cache / "ipv4.json").write_text(json.dumps({"services": ["cached"]}))
+
+ def fetch(url):
+ raise AssertionError(f"should not have fetched {url}")
+
+ data = rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch)
+ self.assertEqual(data, {"services": ["cached"]})
+
+ def test_a_stale_cache_is_refetched(self):
+ path = self.cache / "ipv4.json"
+ path.write_text(json.dumps({"services": ["old"]}))
+ old = time.time() - (rdap._BOOTSTRAP_TTL + 60)
+ import os
+ os.utime(path, (old, old))
+
+ data = rdap.bootstrap(
+ "ipv4", cache_root=self.cache, fetch=lambda url: {"services": ["new"]}
+ )
+ self.assertEqual(data, {"services": ["new"]})
+
+ def test_a_failed_refetch_falls_back_to_the_stale_copy(self):
+ """Losing IANA must not stop the user filing a report.
+
+ Last week's map is almost certainly still correct, and a stale
+ bootstrap fails safe: the worst case is querying a server that has
+ moved, which misses and reads as no contact.
+ """
+ path = self.cache / "ipv4.json"
+ path.write_text(json.dumps({"services": ["old"]}))
+ old = time.time() - (rdap._BOOTSTRAP_TTL + 60)
+ import os
+ os.utime(path, (old, old))
+
+ def fetch(url):
+ raise OSError("network is unreachable")
+
+ data = rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch)
+ self.assertEqual(data, {"services": ["old"]})
+
+ def test_a_failed_fetch_with_no_cache_raises(self):
+ def fetch(url):
+ raise OSError("network is unreachable")
+
+ with self.assertRaises(rdap.BootstrapUnavailable):
+ rdap.bootstrap("ipv4", cache_root=self.cache, fetch=fetch)
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_rdap.Bootstrap -v`
+Expected: FAIL, `AttributeError: module 'abusectl.rdap' has no attribute 'bootstrap'`
+
+- [ ] **Step 3: Implement the bootstrap cache**
+
+Add to `abusectl/rdap.py`, after `http_fetch`:
+
+```python
+import os
+import pathlib
+import time
+
+_BOOTSTRAP_TTL = 7 * 24 * 60 * 60
+_BOOTSTRAP_URL = "https://data.iana.org/rdap/{}.json"
+_REGISTRIES = ("ipv4", "ipv6", "dns")
+
+
+class BootstrapUnavailable(Exception):
+ """No bootstrap data: the fetch failed and there is no cached copy."""
+
+
+def cache_dir() -> pathlib.Path:
+ """Return the bootstrap cache directory, reading XDG at call time.
+
+ Deliberately not inside a case directory: this is a copy of a public
+ map, not evidence.
+ """
+ base = os.environ.get(
+ "XDG_CACHE_HOME", str(pathlib.Path.home() / ".cache")
+ )
+ return pathlib.Path(base) / "abusectl" / "rdap"
+
+
+def bootstrap(registry: str, cache_root=None, fetch=http_fetch) -> dict:
+ """Return an IANA bootstrap document, from cache when it is fresh.
+
+ A failed refetch falls back to the stale copy rather than failing the
+ run. Staleness is safe here: a moved server misses and reads as no
+ contact, whereas losing IANA entirely would stop the user filing a
+ report at all.
+ """
+ if registry not in _REGISTRIES:
+ raise ValueError(f"unknown registry {registry!r}")
+
+ directory = pathlib.Path(cache_root) if cache_root is not None else cache_dir()
+ path = directory / f"{registry}.json"
+
+ cached = None
+ if path.exists():
+ cached = json.loads(path.read_text())
+ if time.time() - path.stat().st_mtime < _BOOTSTRAP_TTL:
+ return cached
+
+ try:
+ data = fetch(_BOOTSTRAP_URL.format(registry))
+ except Exception:
+ if cached is not None:
+ return cached
+ raise BootstrapUnavailable(
+ f"cannot fetch the {registry} bootstrap and no cached copy exists"
+ )
+
+ directory.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(data))
+ return data
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_rdap -v`
+Expected: PASS, 7 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/rdap.py tests/test_rdap.py
+git commit -S -m "feat: cache the IANA bootstrap, and prefer a stale copy to none
+
+Seven day TTL under XDG_CACHE_HOME, deliberately not in a case
+directory: this is a copy of a public map, not evidence.
+
+A failed refetch falls back to the stale copy. Staleness is safe in this
+direction, since a server that has moved simply misses and reads as no
+contact, while losing IANA entirely would stop the user filing a report."
+```
+
+---
+
+### Task 3: Server selection
+
+**Files:**
+- Modify: `abusectl/rdap.py`
+- Test: `tests/test_rdap.py`
+
+Bootstrap documents have the shape
+`{"services": [[["192.0.2.0/24", "198.51.100.0/24"], ["https://rdap.example.invalid/"]]]}`.
+For `dns.json` the first list holds TLDs rather than ranges.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/test_rdap.py`:
+
+```python
+class ServerSelection(unittest.TestCase):
+ IPV4 = {
+ "services": [
+ [["192.0.2.0/24"], ["https://wide.example.invalid/"]],
+ [["192.0.2.128/25"], ["https://narrow.example.invalid/"]],
+ [["198.51.100.0/24"], ["https://other.example.invalid/"]],
+ ]
+ }
+ DNS = {
+ "services": [
+ [["invalid"], ["https://registry.example.invalid/"]],
+ [["test"], ["https://test.example.invalid/"]],
+ ]
+ }
+
+ def test_an_address_selects_its_range(self):
+ self.assertEqual(
+ rdap.server_for_ip("198.51.100.7", self.IPV4),
+ "https://other.example.invalid/",
+ )
+
+ def test_the_longest_prefix_wins(self):
+ """192.0.2.200 is in both /24 and /25; the /25 is more specific.
+
+ Choosing the wider range would ask a registry that has delegated
+ the block away, and its answer would name the wrong operator.
+ """
+ self.assertEqual(
+ rdap.server_for_ip("192.0.2.200", self.IPV4),
+ "https://narrow.example.invalid/",
+ )
+
+ def test_an_unlisted_address_selects_nothing(self):
+ self.assertIsNone(rdap.server_for_ip("203.0.113.9", self.IPV4))
+
+ def test_a_tld_selects_its_registry(self):
+ self.assertEqual(
+ rdap.server_for_tld("invalid", self.DNS),
+ "https://registry.example.invalid/",
+ )
+
+ def test_tld_matching_ignores_case(self):
+ self.assertEqual(
+ rdap.server_for_tld("INVALID", self.DNS),
+ "https://registry.example.invalid/",
+ )
+
+ def test_an_unlisted_tld_selects_nothing(self):
+ self.assertIsNone(rdap.server_for_tld("example", self.DNS))
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_rdap.ServerSelection -v`
+Expected: FAIL, `AttributeError: module 'abusectl.rdap' has no attribute 'server_for_ip'`
+
+- [ ] **Step 3: Implement selection**
+
+Add to `abusectl/rdap.py`:
+
+```python
+import ipaddress
+
+
+def server_for_ip(address: str, bootstrap_data: dict) -> str | None:
+ """Return the RDAP base URL for an address, by longest prefix.
+
+ Longest prefix rather than first match: a block delegated to a new
+ operator appears as a more specific range inside its parent, and the
+ wider one would name the operator that gave it away.
+ """
+ try:
+ ip = ipaddress.ip_address(address)
+ except ValueError:
+ return None
+
+ best_length = -1
+ best_url = None
+
+ for entry in bootstrap_data.get("services", []):
+ ranges, urls = entry[0], entry[1]
+ if not urls:
+ continue
+ for cidr in ranges:
+ try:
+ network = ipaddress.ip_network(cidr, strict=False)
+ except ValueError:
+ continue
+ if ip.version != network.version or ip not in network:
+ continue
+ if network.prefixlen > best_length:
+ best_length = network.prefixlen
+ best_url = urls[0]
+
+ return best_url
+
+
+def server_for_tld(tld: str, bootstrap_data: dict) -> str | None:
+ """Return the RDAP base URL for a TLD, or None when none is published.
+
+ Many TLDs publish no RDAP service at all, and that is a normal
+ outcome rather than a defect.
+ """
+ wanted = tld.lower().strip(".")
+ for entry in bootstrap_data.get("services", []):
+ names, urls = entry[0], entry[1]
+ if not urls:
+ continue
+ if any(name.lower() == wanted for name in names):
+ return urls[0]
+ return None
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_rdap -v`
+Expected: PASS, 13 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/rdap.py tests/test_rdap.py
+git commit -S -m "feat: select an RDAP server by longest prefix and by TLD
+
+Longest prefix rather than first match: a block delegated to a new
+operator appears as a more specific range inside its parent, and the
+wider range would name the operator that gave it away.
+
+A TLD that publishes no RDAP service selects nothing, which is a normal
+outcome for many TLDs rather than a defect."
+```
+
+---
+
+### Task 4: Reading an abuse address out of a jCard
+
+**Files:**
+- Modify: `abusectl/rdap.py`
+- Test: `tests/test_rdap.py`
+
+This is the strict-role rule and its four sub-rules. Every test here has a
+victim named in the spec.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/test_rdap.py`:
+
+```python
+def _entity(roles, emails, entities=None):
+ """Build an RDAP entity in real jCard shape."""
+ properties = [["version", {}, "text", "4.0"]]
+ for address in emails:
+ properties.append(["email", {}, "text", address])
+ entity = {"roles": roles, "vcardArray": ["vcard", properties]}
+ if entities:
+ entity["entities"] = entities
+ return entity
+
+
+class AbuseExtraction(unittest.TestCase):
+ def test_an_abuse_entity_yields_its_address(self):
+ response = {"entities": [_entity(["abuse"], ["abuse@example.invalid"])]}
+ self.assertEqual(
+ rdap.abuse_addresses(response), ["abuse@example.invalid"]
+ )
+
+ def test_a_nested_abuse_entity_is_found(self):
+ """The abuse entity is usually a child of the organisation entity."""
+ response = {
+ "entities": [
+ _entity(
+ ["registrant"], [],
+ entities=[_entity(["abuse"], ["abuse@example.invalid"])],
+ )
+ ]
+ }
+ self.assertEqual(
+ rdap.abuse_addresses(response), ["abuse@example.invalid"]
+ )
+
+ def test_a_technical_only_response_yields_nothing(self):
+ """A technical 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."""
+ response = {"entities": [_entity(["technical"], ["someone@example.invalid"])]}
+ self.assertEqual(rdap.abuse_addresses(response), [])
+
+ def test_every_abuse_address_is_kept(self):
+ """Some netblocks publish two desks, and picking one arbitrarily
+ can drop the one that would have answered."""
+ response = {
+ "entities": [
+ _entity(["abuse"], ["one@example.invalid", "two@example.invalid"])
+ ]
+ }
+ self.assertEqual(
+ rdap.abuse_addresses(response),
+ ["one@example.invalid", "two@example.invalid"],
+ )
+
+ def test_a_newline_in_an_address_is_rejected(self):
+ """The address becomes a mail recipient in report and submit, so a
+ CRLF here is header injection into mail this tool sends."""
+ response = {
+ "entities": [
+ _entity(["abuse"], ["abuse@example.invalid\r\nBcc: victim@example.org"])
+ ]
+ }
+ self.assertEqual(rdap.abuse_addresses(response), [])
+
+ def test_a_non_address_is_rejected(self):
+ response = {"entities": [_entity(["abuse"], ["not an address"])]}
+ self.assertEqual(rdap.abuse_addresses(response), [])
+
+ def test_recursion_is_depth_capped(self):
+ """Remote JSON must not be able to hang the tool."""
+ deep = _entity(["abuse"], ["deep@example.invalid"])
+ for _ in range(10):
+ deep = _entity(["registrant"], [], entities=[deep])
+ self.assertEqual(rdap.abuse_addresses({"entities": [deep]}), [])
+
+ def test_duplicate_addresses_collapse(self):
+ response = {
+ "entities": [
+ _entity(["abuse"], ["abuse@example.invalid"]),
+ _entity(["abuse"], ["abuse@example.invalid"]),
+ ]
+ }
+ self.assertEqual(
+ rdap.abuse_addresses(response), ["abuse@example.invalid"]
+ )
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_rdap.AbuseExtraction -v`
+Expected: FAIL, `AttributeError: module 'abusectl.rdap' has no attribute 'abuse_addresses'`
+
+- [ ] **Step 3: Implement extraction**
+
+Add to `abusectl/rdap.py`:
+
+```python
+import email.utils
+
+_MAX_ENTITY_DEPTH = 4
+
+
+def _valid_address(raw: str) -> str | None:
+ """Return a usable address, or None.
+
+ This value becomes a mail recipient in report and submit, so it is
+ validated where it enters rather than where it is used: a control
+ character here is header injection into mail this tool sends.
+ """
+ if not isinstance(raw, str) or not raw.strip():
+ return None
+ if any(character in raw for character in "\r\n\t"):
+ return None
+ if any(ord(character) < 32 for character in raw):
+ return None
+
+ name, address = email.utils.parseaddr(raw)
+ if not address or address.count("@") != 1:
+ return None
+ local, _, domain = address.partition("@")
+ if not local or not domain or "." not in domain:
+ return None
+ return address
+
+
+def _emails_from_vcard(entity: dict) -> list[str]:
+ """Pull every email property value out of a jCard.
+
+ jCard encodes vCard as nested arrays: vcardArray[1] is a list of
+ [name, params, type, value] properties, so the value is at index 3.
+ """
+ found = []
+ vcard = entity.get("vcardArray")
+ if not isinstance(vcard, list) or len(vcard) < 2:
+ return found
+ for prop in vcard[1]:
+ if not isinstance(prop, list) or len(prop) < 4:
+ continue
+ if prop[0] != "email":
+ continue
+ address = _valid_address(prop[3])
+ if address:
+ found.append(address)
+ return found
+
+
+def abuse_addresses(response: dict) -> list[str]:
+ """Return every published abuse address in an RDAP response.
+
+ STRICT: only an entity whose roles contain "abuse" counts. There is no
+ fallback to a technical or registrant contact, because that is a named
+ human who never volunteered for abuse mail, and no fallback to
+ abuse@<domain> by convention, because for a phishing domain that
+ mailbox belongs to the ATTACKER and mailing it would confirm both the
+ catch and that the user's address is live.
+
+ A links referral is never followed. If the address is not in this
+ response, there is no address: following a URL the response chose for
+ us is an outbound fetch under remote control.
+ """
+ found: list[str] = []
+
+ def walk(entities, depth):
+ if depth > _MAX_ENTITY_DEPTH or not isinstance(entities, list):
+ return
+ for entity in entities:
+ if not isinstance(entity, dict):
+ continue
+ roles = entity.get("roles") or []
+ if isinstance(roles, list) and "abuse" in roles:
+ found.extend(_emails_from_vcard(entity))
+ walk(entity.get("entities"), depth + 1)
+
+ walk(response.get("entities"), 1)
+
+ seen = set()
+ unique = []
+ for address in found:
+ if address not in seen:
+ seen.add(address)
+ unique.append(address)
+ return unique
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_rdap -v`
+Expected: PASS, 21 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/rdap.py tests/test_rdap.py
+git commit -S -m "feat: read abuse addresses from a jCard, strictly
+
+Only an entity whose roles contain abuse counts. No fallback to a
+technical or registrant contact, who is a named human that never
+volunteered for abuse mail, and no fallback to abuse@<domain> by
+convention: for a phishing domain that mailbox belongs to the attacker,
+so constructing it would confirm both the catch and that the reporter's
+address is live.
+
+Addresses are validated where they enter rather than where they are
+used, because the value becomes a mail recipient later and a control
+character in it is header injection into mail this tool sends.
+
+Entity recursion is depth capped so remote JSON cannot hang the tool."
+```
+
+---
+
+### Task 5: Querying, and the label walk
+
+**Files:**
+- Modify: `abusectl/rdap.py`
+- Test: `tests/test_rdap.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/test_rdap.py`:
+
+```python
+class Query(unittest.TestCase):
+ IPV4 = {"services": [[["198.51.100.0/24"], ["https://rir.example.invalid/"]]]}
+ DNS = {"services": [[["invalid"], ["https://registry.example.invalid/"]]]}
+
+ def test_an_ip_query_hits_the_selected_server(self):
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ return {"handle": "NET-1", "entities": []}
+
+ result = rdap.query_ip("198.51.100.7", self.IPV4, fetch=fetch)
+
+ self.assertEqual(calls, ["https://rir.example.invalid/ip/198.51.100.7"])
+ self.assertEqual(result["handle"], "NET-1")
+
+ def test_an_unlisted_ip_is_not_queried(self):
+ def fetch(url):
+ raise AssertionError(f"should not have fetched {url}")
+
+ self.assertIsNone(rdap.query_ip("203.0.113.9", self.IPV4, fetch=fetch))
+
+ def test_the_label_walk_stops_at_the_first_answer(self):
+ """mail.deep.example.invalid is not registrable; example.invalid is.
+
+ The registry is the authority on what is registrable, which is why
+ this walks rather than carrying a Public Suffix List that would go
+ stale weekly.
+ """
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ if url.endswith("/domain/example.invalid"):
+ return {"handle": "DOM-1", "entities": []}
+ raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
+
+ result, queried = rdap.query_domain(
+ "mail.deep.example.invalid", self.DNS, fetch=fetch
+ )
+
+ self.assertEqual(queried, "example.invalid")
+ self.assertEqual(result["handle"], "DOM-1")
+ self.assertEqual(len(calls), 3)
+
+ def test_the_walk_never_queries_a_bare_tld(self):
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
+
+ result, queried = rdap.query_domain(
+ "deep.example.invalid", self.DNS, fetch=fetch
+ )
+
+ self.assertIsNone(result)
+ self.assertNotIn("https://registry.example.invalid/domain/invalid", calls)
+
+ def test_a_tld_with_no_server_is_not_queried(self):
+ def fetch(url):
+ raise AssertionError(f"should not have fetched {url}")
+
+ result, queried = rdap.query_domain(
+ "example.test", self.DNS, fetch=fetch
+ )
+ self.assertIsNone(result)
+
+ def test_the_walk_is_capped(self):
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ raise urllib.error.HTTPError(url, 404, "Not Found", {}, None)
+
+ host = "a.b.c.d.e.f.g.example.invalid"
+ rdap.query_domain(host, self.DNS, fetch=fetch)
+ self.assertLessEqual(len(calls), rdap._MAX_LABEL_WALK)
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_rdap.Query -v`
+Expected: FAIL, `AttributeError: module 'abusectl.rdap' has no attribute 'query_ip'`
+
+- [ ] **Step 3: Implement querying**
+
+Add to `abusectl/rdap.py`:
+
+```python
+_MAX_LABEL_WALK = 5
+
+
+def query_ip(address: str, bootstrap_data: dict, fetch=http_fetch) -> dict | None:
+ """Query the registry responsible for an address.
+
+ Returns None when no registry is listed for it, which is a normal
+ outcome rather than an error.
+ """
+ base = server_for_ip(address, bootstrap_data)
+ if base is None:
+ return None
+ return fetch(f"{base.rstrip('/')}/ip/{address}")
+
+
+def query_domain(
+ host: str, bootstrap_data: dict, fetch=http_fetch
+) -> tuple[dict | None, str | None]:
+ """Query for a host, walking up the labels to find the registrable name.
+
+ RDAP wants the registrable domain, and mail.deep.example.invalid is not
+ one. Rather than carrying a Public Suffix List, which is a transcribed
+ table that goes stale weekly, this asks the registry: it is the
+ authority on what is registrable.
+
+ Returns (response, queried_name). A bare TLD is never queried.
+ """
+ labels = host.lower().strip(".").split(".")
+ if len(labels) < 2:
+ return None, None
+
+ tld = labels[-1]
+ base = server_for_tld(tld, bootstrap_data)
+ if base is None:
+ return None, None
+ base = base.rstrip("/")
+
+ attempts = 0
+ # Stop before the bare TLD: range end is len(labels) - 1, so the last
+ # candidate is the two-label name.
+ for start in range(0, len(labels) - 1):
+ if attempts >= _MAX_LABEL_WALK:
+ break
+ candidate = ".".join(labels[start:])
+ attempts += 1
+ try:
+ return fetch(f"{base}/domain/{candidate}"), candidate
+ except Exception:
+ continue
+
+ return None, None
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_rdap -v`
+Expected: PASS, 27 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/rdap.py tests/test_rdap.py
+git commit -S -m "feat: query RDAP, walking up the labels for a registrable domain
+
+RDAP wants the registrable domain and a deep host is not one. Rather
+than bundling a Public Suffix List, which is a transcribed table that
+goes stale weekly and is the failure init.PROVIDERS already documents,
+this asks the registry, which is the authority on what is registrable.
+
+The walk is capped and never queries a bare TLD."
+```
+
+---
+
+### Task 6: The worklist, and the fourth property
+
+**Files:**
+- Create: `abusectl/contacts.py`
+- Test: `tests/test_contacts.py`
+
+This task holds the fourth non-negotiable property. Write these tests
+carefully; they are the ones that matter.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/test_contacts.py`:
+
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Tests for turning indicators into abuse contacts."""
+
+import unittest
+
+from abusectl import contacts
+
+
+class Worklist(unittest.TestCase):
+ def test_ips_and_domains_are_resolvable(self):
+ iocs = [
+ {"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"},
+ {"id": "ioc-2", "type": "domain", "value": "example.invalid"},
+ ]
+ work = contacts.worklist(iocs)
+ self.assertEqual(
+ {(item.kind, item.query) for item in work},
+ {("ip", "198.51.100.7"), ("domain", "example.invalid")},
+ )
+
+ def test_hashes_and_observations_are_not_resolvable(self):
+ iocs = [
+ {"id": "ioc-1", "type": "sha256", "value": "e3b0c442"},
+ {"id": "ioc-2", "type": "observation",
+ "value": "display-name-carries-address"},
+ ]
+ self.assertEqual(contacts.worklist(iocs), [])
+
+ def test_a_url_contributes_only_its_host(self):
+ """THE FOURTH PROPERTY. A query discloses what the user is looking
+ at, and a URL path can carry recipient identity that
+ suspect_path_segments deliberately flags rather than redacts."""
+ iocs = [{
+ "id": "ioc-1", "type": "url",
+ "value": "https://login.example.invalid/verify/victim%40example.org?e=REDACTED",
+ }]
+ work = contacts.worklist(iocs)
+ self.assertEqual(len(work), 1)
+ self.assertEqual(work[0].kind, "domain")
+ self.assertEqual(work[0].query, "login.example.invalid")
+
+ def test_url_userinfo_never_reaches_the_query(self):
+ iocs = [{
+ "id": "ioc-1", "type": "url",
+ "value": "https://victim%40example.org:secret@login.example.invalid/x",
+ }]
+ work = contacts.worklist(iocs)
+ self.assertEqual(work[0].query, "login.example.invalid")
+
+ def test_a_url_port_is_stripped(self):
+ iocs = [{"id": "ioc-1", "type": "url",
+ "value": "https://login.example.invalid:8443/x"}]
+ self.assertEqual(contacts.worklist(iocs)[0].query, "login.example.invalid")
+
+ def test_a_url_host_that_is_an_ip_resolves_as_an_ip(self):
+ iocs = [{"id": "ioc-1", "type": "url",
+ "value": "http://198.51.100.7/login"}]
+ work = contacts.worklist(iocs)
+ self.assertEqual(work[0].kind, "ip")
+ self.assertEqual(work[0].query, "198.51.100.7")
+
+ def test_a_bracketed_ipv6_url_host_resolves_as_an_ip(self):
+ iocs = [{"id": "ioc-1", "type": "url",
+ "value": "http://[2001:db8::1]/login"}]
+ work = contacts.worklist(iocs)
+ self.assertEqual(work[0].kind, "ip")
+ self.assertEqual(work[0].query, "2001:db8::1")
+
+ def test_hosts_fold_and_keep_every_contributing_ioc(self):
+ """Twenty URLs on one host must produce one query."""
+ iocs = [
+ {"id": "ioc-1", "type": "url", "value": "https://a.example.invalid/one"},
+ {"id": "ioc-2", "type": "url", "value": "https://a.example.invalid/two"},
+ {"id": "ioc-3", "type": "domain", "value": "a.example.invalid"},
+ ]
+ work = contacts.worklist(iocs)
+ self.assertEqual(len(work), 1)
+ self.assertEqual(work[0].iocs, ["ioc-1", "ioc-2", "ioc-3"])
+
+ def test_a_trailing_dot_folds_with_the_bare_host(self):
+ iocs = [
+ {"id": "ioc-1", "type": "domain", "value": "example.invalid."},
+ {"id": "ioc-2", "type": "domain", "value": "example.invalid"},
+ ]
+ self.assertEqual(len(contacts.worklist(iocs)), 1)
+
+ def test_an_untrusted_hop_is_still_resolved(self):
+ """A forged chain's IP may still be the real sender's; the
+ confidence marker stays in the manifest for review."""
+ iocs = [{"id": "ioc-1", "type": "ipv4", "value": "203.0.113.99",
+ "confidence": "untrusted-hop"}]
+ self.assertEqual(len(contacts.worklist(iocs)), 1)
+
+ def test_a_malformed_url_contributes_nothing(self):
+ iocs = [{"id": "ioc-1", "type": "url", "value": "not a url"}]
+ self.assertEqual(contacts.worklist(iocs), [])
+
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_contacts -v`
+Expected: FAIL, `ModuleNotFoundError: No module named 'abusectl.contacts'`
+
+- [ ] **Step 3: Implement the worklist**
+
+Create `abusectl/contacts.py`:
+
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""Indicators to abuse contacts: which ones resolve, and to what.
+
+THE FOURTH NON-NEGOTIABLE PROPERTY lives here. An RDAP query tells a third
+party what the user is looking at, so a query carries a BARE HOST OR IP
+ADDRESS and never a 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, and that decision is safe only
+while the URL stays local. Property 1 governs what is PUBLISHED; a query
+is a disclosure that appears in no report, so property 1 does not cover
+it and this one does.
+
+Concretely: a url indicator contributes its HOST to the worklist and
+nothing else. Path, query and fragment never leave the machine.
+
+This is a trap rather than 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.
+"""
+
+import ipaddress
+import urllib.parse
+from dataclasses import dataclass, field
+
+from . import rdap
+
+
+@dataclass
+class WorkItem:
+ """One thing to ask a registry about, and every indicator behind it."""
+
+ kind: str # "ip" or "domain"
+ query: str
+ iocs: list[str] = field(default_factory=list)
+
+
+def _host_of(url: str) -> str | None:
+ """Return the bare host of a URL: no userinfo, no port, no path.
+
+ urlsplit().hostname does all three, which is why it is used rather
+ than netloc: netloc still carries userinfo and a port.
+ """
+ try:
+ parts = urllib.parse.urlsplit(url)
+ except ValueError:
+ return None
+ host = parts.hostname
+ if not host:
+ return None
+ return host.strip(".").lower() or None
+
+
+def _is_ip(value: str) -> bool:
+ try:
+ ipaddress.ip_address(value)
+ return True
+ except ValueError:
+ return False
+
+
+def worklist(iocs: list[dict]) -> list[WorkItem]:
+ """Build the deduplicated list of queries for a set of indicators.
+
+ Hosts fold: twenty URLs on one host produce one query, and the item
+ keeps every indicator id that contributed so nothing is lost.
+ """
+ items: dict[tuple[str, str], WorkItem] = {}
+
+ def add(kind, query, ioc_id):
+ key = (kind, query)
+ if key not in items:
+ items[key] = WorkItem(kind=kind, query=query)
+ if ioc_id not in items[key].iocs:
+ items[key].iocs.append(ioc_id)
+
+ for ioc in iocs:
+ ioc_type = ioc.get("type")
+ value = ioc.get("value") or ""
+ ioc_id = ioc.get("id")
+
+ if ioc_type in ("ipv4", "ipv6"):
+ if _is_ip(value):
+ add("ip", value, ioc_id)
+ elif ioc_type == "domain":
+ host = value.strip(".").lower()
+ if host:
+ add("ip" if _is_ip(host) else "domain", host, ioc_id)
+ elif ioc_type == "url":
+ host = _host_of(value)
+ if host:
+ add("ip" if _is_ip(host) else "domain", host, ioc_id)
+
+ return list(items.values())
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_contacts -v`
+Expected: PASS, 11 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/contacts.py tests/test_contacts.py
+git commit -S -m "feat: build the contacts worklist, host only
+
+Adds the fourth non-negotiable property: a query carries a bare host or
+IP and never a URL. An RDAP query discloses what the user is looking at,
+and a URL path can carry recipient identity that parse deliberately
+flags rather than redacts, because a path segment may be the thing being
+reported. That decision is safe only while the URL stays local.
+
+Property 1 governs what is published and a query appears in no report,
+so property 1 does not cover this and this property does.
+
+Hosts fold, so twenty URLs on one host make one query while the item
+keeps every indicator id behind it."
+```
+
+---
+
+### Task 7: Resolving the worklist
+
+**Files:**
+- Modify: `abusectl/contacts.py`
+- Test: `tests/test_contacts.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/test_contacts.py`, before the `if __name__` block:
+
+```python
+class Resolve(unittest.TestCase):
+ IPV4 = {"services": [[["198.51.100.0/24"], ["https://rir.example.invalid/"]]]}
+ IPV6 = {"services": []}
+ DNS = {"services": [[["invalid"], ["https://registry.example.invalid/"]]]}
+
+ def _bootstraps(self):
+ return {"ipv4": self.IPV4, "ipv6": self.IPV6, "dns": self.DNS}
+
+ def test_an_ip_resolves_to_its_abuse_desk(self):
+ def fetch(url):
+ return {
+ "handle": "NET-1",
+ "entities": [{
+ "roles": ["abuse"],
+ "vcardArray": ["vcard", [
+ ["version", {}, "text", "4.0"],
+ ["email", {}, "text", "abuse@example.invalid"],
+ ]],
+ }],
+ }
+
+ iocs = [{"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"}]
+ result = contacts.resolve(
+ iocs, bootstraps=self._bootstraps(), fetch=fetch
+ )
+
+ self.assertEqual(len(result), 1)
+ self.assertEqual(result[0]["iocs"], ["ioc-1"])
+ self.assertEqual(result[0]["query"], "198.51.100.7")
+ self.assertEqual(result[0]["abuse"], ["abuse@example.invalid"])
+ self.assertEqual(result[0]["handle"], "NET-1")
+ self.assertNotIn("error", result[0])
+
+ def test_no_abuse_role_records_a_reason_not_an_error(self):
+ def fetch(url):
+ return {"handle": "NET-2", "entities": []}
+
+ iocs = [{"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"}]
+ result = contacts.resolve(
+ iocs, bootstraps=self._bootstraps(), fetch=fetch
+ )
+
+ self.assertEqual(result[0]["abuse"], [])
+ self.assertEqual(result[0]["error"], "no abuse role published")
+
+ def test_no_rdap_server_records_a_reason(self):
+ def fetch(url):
+ raise AssertionError(f"should not have fetched {url}")
+
+ iocs = [{"id": "ioc-1", "type": "domain", "value": "example.test"}]
+ result = contacts.resolve(
+ iocs, bootstraps=self._bootstraps(), fetch=fetch
+ )
+
+ self.assertEqual(result[0]["abuse"], [])
+ self.assertIn("no rdap server", result[0]["error"])
+
+ def test_a_network_failure_is_per_query_and_does_not_stop_the_run(self):
+ def fetch(url):
+ if "198.51.100.7" in url:
+ raise OSError("connection timed out")
+ return {
+ "handle": "DOM-1",
+ "entities": [{
+ "roles": ["abuse"],
+ "vcardArray": ["vcard", [
+ ["version", {}, "text", "4.0"],
+ ["email", {}, "text", "abuse@example.invalid"],
+ ]],
+ }],
+ }
+
+ iocs = [
+ {"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"},
+ {"id": "ioc-2", "type": "domain", "value": "example.invalid"},
+ ]
+ result = contacts.resolve(
+ iocs, bootstraps=self._bootstraps(), fetch=fetch
+ )
+
+ self.assertEqual(len(result), 2)
+ failed = [r for r in result if r["query"] == "198.51.100.7"][0]
+ worked = [r for r in result if r["query"] == "example.invalid"][0]
+ self.assertIn("connection timed out", failed["error"])
+ self.assertEqual(worked["abuse"], ["abuse@example.invalid"])
+
+ def test_one_query_per_host_however_many_iocs(self):
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ return {"handle": "DOM-1", "entities": []}
+
+ iocs = [
+ {"id": f"ioc-{n}", "type": "url",
+ "value": f"https://a.example.invalid/page{n}"}
+ for n in range(20)
+ ]
+ result = contacts.resolve(
+ iocs, bootstraps=self._bootstraps(), fetch=fetch
+ )
+
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(len(result[0]["iocs"]), 20)
+
+ def test_no_query_ever_carries_a_path(self):
+ """THE FOURTH PROPERTY, asserted at the transport."""
+ calls = []
+
+ def fetch(url):
+ calls.append(url)
+ return {"handle": "DOM-1", "entities": []}
+
+ iocs = [{
+ "id": "ioc-1", "type": "url",
+ "value": "https://a.example.invalid/verify/victim%40example.org?e=x",
+ }]
+ contacts.resolve(iocs, bootstraps=self._bootstraps(), fetch=fetch)
+
+ for url in calls:
+ self.assertNotIn("victim", url)
+ self.assertNotIn("verify", url)
+ self.assertNotIn("%40", url)
+ self.assertNotIn("?", url)
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `python3 -m unittest tests.test_contacts.Resolve -v`
+Expected: FAIL, `AttributeError: module 'abusectl.contacts' has no attribute 'resolve'`
+
+- [ ] **Step 3: Implement resolve**
+
+Add to `abusectl/contacts.py`:
+
+```python
+def resolve(iocs: list[dict], bootstraps: dict, fetch=rdap.http_fetch) -> list[dict]:
+ """Resolve every resolvable indicator to an abuse contact.
+
+ bootstraps is {"ipv4": ..., "ipv6": ..., "dns": ...}, passed in rather
+ than fetched here so this function stays testable with no network and
+ so the caller owns the cache policy.
+
+ A failure is recorded per query and never stops the run: a timeout on
+ one indicator must not cost the contacts that did resolve. A missing
+ contact is a normal outcome, not an error.
+ """
+ results = []
+
+ for item in worklist(iocs):
+ entry = {
+ "iocs": item.iocs,
+ "query": item.query,
+ "abuse": [],
+ "source": "rdap",
+ }
+
+ try:
+ if item.kind == "ip":
+ family = "ipv6" if ":" in item.query else "ipv4"
+ response = rdap.query_ip(
+ item.query, bootstraps.get(family, {}), fetch=fetch
+ )
+ if response is None:
+ entry["error"] = "no rdap server for this range"
+ results.append(entry)
+ continue
+ else:
+ response, queried = rdap.query_domain(
+ item.query, bootstraps.get("dns", {}), fetch=fetch
+ )
+ if response is None:
+ entry["error"] = "no rdap server for this tld, or no answer"
+ results.append(entry)
+ continue
+ if queried and queried != item.query:
+ entry["queried"] = queried
+ except Exception as exc:
+ entry["error"] = f"{type(exc).__name__}: {exc}"
+ results.append(entry)
+ continue
+
+ handle = response.get("handle")
+ if handle:
+ entry["handle"] = handle
+
+ addresses = rdap.abuse_addresses(response)
+ entry["abuse"] = addresses
+ if not addresses:
+ entry["error"] = "no abuse role published"
+
+ results.append(entry)
+
+ return results
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_contacts -v`
+Expected: PASS, 17 tests
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add abusectl/contacts.py tests/test_contacts.py
+git commit -S -m "feat: resolve the worklist to abuse contacts
+
+Failure is per query and never stops the run: a timeout on one indicator
+must not cost the contacts that did resolve, and a missing contact is a
+normal outcome rather than an error.
+
+Bootstraps are passed in rather than fetched here, so this stays
+testable with no network and the caller owns the cache policy."
+```
+
+---
+
+### Task 8: The `contacts` subcommand
+
+**Files:**
+- Modify: `abusectl/cli.py`
+- Modify: `tests/test_cli.py`
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `tests/test_cli.py`, inside the existing test module and before any
+`if __name__` block. Match the surrounding style; if existing dispatch tests
+use a helper, reuse it.
+
+```python
+class ContactsCommand(unittest.TestCase):
+ def test_contacts_rewrites_the_manifest(self):
+ import json
+ import tempfile
+ from pathlib import Path
+ from unittest import mock
+
+ from abusectl import case
+
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ created = case.create(root, b"From: sender@example.invalid\r\n\r\nbody\r\n")
+ manifest = case.load(created.path)
+ manifest["iocs"] = [
+ {"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"}
+ ]
+ case.save(created.path, manifest)
+
+ fake_contacts = [{
+ "iocs": ["ioc-1"], "query": "198.51.100.7",
+ "abuse": ["abuse@example.invalid"], "source": "rdap",
+ }]
+
+ with mock.patch("abusectl.cli.contacts_module.resolve",
+ return_value=fake_contacts) as resolve, \
+ mock.patch("abusectl.cli.rdap_module.bootstrap",
+ return_value={"services": []}):
+ code = cli.main(["contacts", str(created.path)])
+
+ self.assertEqual(code, cli.EXIT_OK)
+ self.assertTrue(resolve.called)
+ written = case.load(created.path)
+ self.assertEqual(written["contacts"], fake_contacts)
+
+ def test_a_missing_case_is_an_error_not_a_traceback(self):
+ code = cli.main(["contacts", "/nonexistent/case/path"])
+ self.assertEqual(code, cli.EXIT_ERROR)
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `python3 -m unittest tests.test_cli.ContactsCommand -v`
+Expected: FAIL, `SystemExit: 2` from argparse, since the subcommand does not exist
+
+- [ ] **Step 3: Wire the subcommand**
+
+In `abusectl/cli.py`, add to the imports at the top, matching the existing
+import style (`from . import parse as parse_module`):
+
+```python
+from . import contacts as contacts_module
+from . import rdap as rdap_module
+```
+
+In `_build_parser()`, after the `parse_parser` block and before `return parser`:
+
+```python
+ contacts_parser = subparsers.add_parser(
+ "contacts", help="resolve abuse contacts for a case"
+ )
+ contacts_parser.add_argument("case", type=Path)
+```
+
+Add the handler, after `_cmd_parse`:
+
+```python
+def _cmd_contacts(args) -> int:
+ """Resolve abuse contacts and rewrite the manifest.
+
+ A re-run overwrites contacts[] wholesale rather than merging. A merge
+ would let 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.
+ """
+ try:
+ manifest = case.load(args.case)
+ except FileNotFoundError:
+ print(f"abusectl contacts: no case at {args.case}", file=sys.stderr)
+ return EXIT_ERROR
+ except (ValueError, OSError) as exc:
+ print(f"abusectl contacts: {args.case}: {exc}", file=sys.stderr)
+ return EXIT_ERROR
+
+ try:
+ bootstraps = {
+ name: rdap_module.bootstrap(name) for name in ("ipv4", "ipv6", "dns")
+ }
+ except rdap_module.BootstrapUnavailable as exc:
+ print(f"abusectl contacts: {exc}", file=sys.stderr)
+ return EXIT_ERROR
+
+ resolved = contacts_module.resolve(manifest.get("iocs", []), bootstraps)
+
+ manifest["contacts"] = resolved
+ case.save(args.case, manifest)
+
+ unresolved = sum(1 for entry in resolved if not entry["abuse"])
+ print(f"{len(resolved)} contacts, {unresolved} without an abuse address")
+ return EXIT_OK
+```
+
+In `main()`, add the dispatch beside the existing ones:
+
+```python
+ if args.command == "contacts":
+ return _cmd_contacts(args)
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `python3 -m unittest tests.test_cli -v`
+Expected: PASS, including the two new tests
+
+- [ ] **Step 5: Run the whole suite**
+
+Run: `python3 -m unittest discover tests`
+Expected: OK, 112 existing plus the new tests
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add abusectl/cli.py tests/test_cli.py
+git commit -S -m "feat: add the contacts subcommand
+
+A re-run overwrites contacts[] wholesale rather than merging. A merge
+would let a contact resolved a week ago survive into a report filed
+today, which is the stale-address hazard the response caching policy
+already refuses, and overwriting makes a re-run always safe, which
+matters because a partial network failure makes re-running the natural
+next step."
+```
+
+---
+
+### Task 9: Prove the suite is still offline
+
+**Files:**
+- Test: `tests/test_offline.py` (create only if no equivalent exists)
+
+The umbrella design's property 2 is verified by running the suite with sockets
+raising. Confirm that still holds now that a network module exists.
+
+- [ ] **Step 1: Check whether the guard already exists**
+
+Run: `grep -rn "getaddrinfo\|create_connection" tests/`
+
+If a test already blocks sockets suite-wide, skip to Step 3 and just run it.
+
+- [ ] **Step 2: If none exists, create `tests/test_offline.py`**
+
+```python
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+"""The suite must pass with no network, including the network modules.
+
+A network module that can only be tested with a network is a module that
+stops being tested. rdap and contacts both take an injected fetch, and
+this asserts that the default is never reached by accident during a test
+run: parse stays pure, and nothing above it opens a socket unasked.
+"""
+
+import socket
+import unittest
+from unittest import mock
+
+from abusectl import contacts, parse, rdap
+
+
+class NothingOpensASocket(unittest.TestCase):
+ def setUp(self):
+ for name in ("socket", "create_connection", "getaddrinfo"):
+ patcher = mock.patch.object(
+ socket, name,
+ side_effect=AssertionError(f"socket.{name} was called"),
+ )
+ patcher.start()
+ self.addCleanup(patcher.stop)
+
+ def test_parsing_opens_no_socket(self):
+ raw = (b"Received: from relay.example.invalid ([192.0.2.10])\r\n"
+ b"From: sender@example.invalid\r\n"
+ b"Subject: test\r\n\r\nbody\r\n")
+ parse.iocs(raw, trusted=["192.0.2.0/24"])
+
+ def test_resolving_with_an_injected_fetch_opens_no_socket(self):
+ iocs = [{"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"}]
+ bootstraps = {
+ "ipv4": {"services": [[["198.51.100.0/24"],
+ ["https://rir.example.invalid/"]]]},
+ "ipv6": {"services": []},
+ "dns": {"services": []},
+ }
+ result = contacts.resolve(
+ iocs, bootstraps=bootstraps,
+ fetch=lambda url: {"handle": "NET-1", "entities": []},
+ )
+ self.assertEqual(len(result), 1)
+
+ def test_the_real_transport_is_never_the_default_in_a_test(self):
+ """Sanity: http_fetch exists and is the documented default."""
+ self.assertIs(contacts.resolve.__defaults__[-1], rdap.http_fetch)
+
+
+if __name__ == "__main__":
+ unittest.main()
+```
+
+- [ ] **Step 3: Run the whole suite**
+
+Run: `python3 -m unittest discover tests`
+Expected: OK, all tests pass
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/test_offline.py
+git commit -S -m "test: prove the suite still opens no socket
+
+The umbrella design verifies property 2 by running with sockets raising.
+Now that a network module exists, that has to stay true for the WHOLE
+suite rather than for everything except contacts: a network module that
+can only be tested with a network is a module that stops being tested."
+```
+
+---
+
+### Task 10: Sweep A, offline, over the user's real spam
+
+**Files:**
+- Scratchpad only. Nothing in this task is committed to the repository.
+
+**Ask the user before reading their mail.** This is required by AGENTS.md.
+
+- [ ] **Step 1: Ask permission**
+
+Ask: "May I run the offline sweep over your spam corpus? It reads
+`tag:spam` from your notmuch index, builds the contacts worklist with a fake
+transport, and sends no packets."
+
+- [ ] **Step 2: Write the sweep script in the scratchpad**
+
+Write to the session scratchpad directory, NEVER into the repository:
+
+```python
+"""Sweep A: build the contacts worklist over real spam. No packets."""
+
+import re
+import subprocess
+import sys
+
+# Run this from the abusectl checkout, or point PYTHONPATH at it.
+sys.path.insert(0, "/home/you/path/to/abusectl")
+
+from abusectl import contacts, parse
+
+TRUSTED = ["192.0.2.0/24"] # replace with the user's real config values
+
+mids = subprocess.run(
+ ["notmuch", "search", "--output=messages", "tag:spam"],
+ capture_output=True, text=True, check=True,
+).stdout.split()
+
+hosts = set()
+walk_depths = []
+malformed = []
+crashes = 0
+folded = 0
+
+for mid in mids:
+ try:
+ raw = subprocess.run(
+ ["notmuch", "show", "--format=raw", mid],
+ capture_output=True, check=True,
+ ).stdout
+ iocs = parse.iocs(raw, trusted=TRUSTED)
+ except Exception:
+ crashes += 1
+ continue
+
+ work = contacts.worklist(iocs)
+ folded += sum(len(item.iocs) for item in work) - len(work)
+
+ for item in work:
+ hosts.add((item.kind, item.query))
+ # THE ASSERTION: a query is a bare host or IP, nothing else.
+ if any(c in item.query for c in "/?#@:") and item.kind != "ip":
+ malformed.append(item.query)
+ if item.kind == "domain":
+ walk_depths.append(item.query.count(".") + 1)
+
+print(f"messages: {len(mids)}")
+print(f"crashes: {crashes}")
+print(f"unique targets: {len(hosts)}")
+print(f"iocs folded away:{folded}")
+print(f"malformed: {len(malformed)}")
+if walk_depths:
+ print(f"labels min/max: {min(walk_depths)}/{max(walk_depths)}")
+assert not malformed, malformed[:5]
+print("OK: every query was a bare host or IP")
+```
+
+- [ ] **Step 3: Run it and record the counts**
+
+Run the script. Report to the user: message count, crash count, unique
+targets, how many indicators folded, label depth range, and whether the
+assertion held.
+
+**What may leave this script:** counts, tallies, TLDs, error reasons, walk
+depths, whether any query was malformed.
+**What may not:** an address, a real domain, a real abuse contact, or a URL
+from a real message.
+
+- [ ] **Step 4: If the sweep finds a defect, reproduce it synthetically**
+
+Write a new test in `tests/test_contacts.py` using `example.invalid` and RFC
+5737 values that reproduces the shape, watch it fail, fix, watch it pass,
+commit. The real message stays in the scratchpad.
+
+- [ ] **Step 5: Nothing to commit if the sweep was clean**
+
+Do not commit the script. Record the outcome in the handoff instead.
+
+---
+
+### Task 11: Sweep B, online, a deliberate handful
+
+**Files:**
+- Scratchpad only.
+
+**This task is OUTWARD-FACING and needs an explicit go-ahead**, separate from
+Task 10's. It discloses to registries, and possibly to the attacker's own
+registrar, which netblocks and domains the user is investigating, from their
+address, at a known time. That cannot be undone.
+
+- [ ] **Step 1: Ask for explicit permission**
+
+Ask: "Sweep B queries real registries for ten to twenty hand-picked
+indicators. Unlike every test so far this discloses what you are
+investigating to third parties and cannot be undone. Shall I run it?"
+
+If the user declines, stop here. The module is still fully tested; sweep B
+only confirms real response shapes.
+
+- [ ] **Step 2: Pick the sample by hand**
+
+Ten to twenty targets across distinct netblocks and TLDs, chosen with the
+user. Include at least one of each: a well-known netblock, a deep host that
+will exercise the label walk, and a TLD likely to publish no RDAP.
+
+- [ ] **Step 3: Run against the real transport**
+
+```python
+from abusectl import contacts, rdap
+
+bootstraps = {name: rdap.bootstrap(name) for name in ("ipv4", "ipv6", "dns")}
+
+iocs = [
+ {"id": "ioc-1", "type": "ipv4", "value": "..."}, # filled in with the user
+]
+
+for entry in contacts.resolve(iocs, bootstraps=bootstraps):
+ print(entry["query"], "->", len(entry["abuse"]), "addresses",
+ entry.get("error", ""))
+```
+
+Print the COUNT of addresses, never the addresses themselves.
+
+- [ ] **Step 4: Record shapes, fix any parsing defect**
+
+If a real response shape does not parse, reproduce it as a synthetic fixture
+with every address, handle and range replaced, add the failing test, fix,
+commit.
+
+- [ ] **Step 5: Report outcome**
+
+Counts and shapes only. No real contact reaches the repository or the
+conversation.
+
+---
+
+### Task 12: Documentation
+
+**Files:**
+- Modify: `AGENTS.md`
+- Modify: `README.md`
+- Modify: `docs/BACKLOG.md` if anything was deferred
+
+- [ ] **Step 1: Add the fourth property to AGENTS.md**
+
+The section is titled "THREE PROPERTIES THAT ARE NOT NEGOTIABLE". Rename it
+to "FOUR PROPERTIES THAT ARE NOT NEGOTIABLE" and add, after property 3:
+
+```markdown
+### 4. A query carries a bare host or IP, never a URL
+
+`contacts` is the first part that talks to anyone. An RDAP query tells a
+third party what the user is looking at, so it carries a BARE HOST OR IP
+ADDRESS and nothing else.
+
+Property 1 governs what is PUBLISHED. A query appears in no report, so
+property 1 does not cover it. A URL path can carry recipient identity, and
+`suspect_path_segments` deliberately FLAGS those rather than redacting them,
+which is safe only while the URL stays local.
+
+`contacts.worklist()` reduces a `url` indicator to `urlsplit().hostname`,
+which drops userinfo, port and path together. `tests/test_contacts.py`
+asserts at the transport that no query ever carried a path, and the offline
+sweep asserts the same thing against real mail.
+```
+
+- [ ] **Step 2: Update the architecture block in AGENTS.md**
+
+In the `## Architecture` section, move `contacts` out of "Planned" and into
+the module list:
+
+```
+ contacts.py IOCs -> abuse contacts network, read-only
+ rdap.py bootstrap, query, jCard network, read-only
+```
+
+Update the Planned line to name only `report`, `submit` and `retry`.
+
+- [ ] **Step 3: Document the command in README.md**
+
+Add `contacts` to the usage section beside `parse`, matching the existing
+style, including that it needs network and that a re-run is safe.
+
+- [ ] **Step 4: Run the suite one final time**
+
+Run: `python3 -m unittest discover tests`
+Expected: OK
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add AGENTS.md README.md docs/BACKLOG.md
+git commit -S -m "docs: record the fourth property and the contacts command
+
+An RDAP query discloses what the user is looking at, and property 1
+covers only what is published, so the query rule needed stating in its
+own right beside the other three."
+```
+
+---
+
+## Self-review against the spec
+
+| Spec section | Task |
+|---|---|
+| Modules, injected fetch | 1, 6, 9 |
+| Fourth property | 6, 7, 10, 12 |
+| What gets resolved (table) | 6 |
+| Bootstrap, TTL, stale fallback | 2 |
+| Server selection, longest prefix | 3 |
+| Label walk replacing a PSL | 5 |
+| Transport, timeout, redirect cap, no downgrade | 1 |
+| Caching policy, in-run dedup | 2, 6 |
+| Strict abuse role, four rules | 4 |
+| Manifest shape, `iocs` and `abuse` as lists | 7 |
+| Re-run overwrites wholesale | 8 |
+| Failure is per query | 7 |
+| Test list | 3, 4, 5, 6, 7 |
+| Sweep A offline | 10 |
+| Sweep B online, ask first | 11 |
+| Deliberately absent (no PSL, no whois, no disk response cache) | 2, 5 |
+
+**Note on test counts:** the running totals in each task assume the tests
+above are added in order and nothing else changes. If a count is off by a
+few, that is bookkeeping rather than a failure; what matters is that the
+named tests pass and `python3 -m unittest discover tests` is green.
+
+**Note on `no rdap server` wording:** Task 7's test asserts
+`"no rdap server" in entry["error"]`, and the implementation writes two
+different suffixes for the IP and domain cases. Keep the substring stable if
+you reword either message.