diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-09 09:24:56 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-09 09:24:56 +0200 |
| commit | 3eba0ecca47435f04e1558c5a7c60479f76cf973 (patch) | |
| tree | cc01601cd8b35cc696330812bf7a785ea58fcc9e | |
| parent | 38996c0b24ba19974e9c192bd5726ad3e9bc4dd1 (diff) | |
| download | abusectl-3eba0ecca47435f04e1558c5a7c60479f76cf973.tar.gz abusectl-3eba0ecca47435f04e1558c5a7c60479f76cf973.zip | |
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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
| -rw-r--r-- | abusectl/contacts.py | 108 | ||||
| -rw-r--r-- | tests/test_contacts.py | 113 |
2 files changed, 221 insertions, 0 deletions
diff --git a/abusectl/contacts.py b/abusectl/contacts.py new file mode 100644 index 0000000..c4c263d --- /dev/null +++ b/abusectl/contacts.py @@ -0,0 +1,108 @@ +# 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()) diff --git a/tests/test_contacts.py b/tests/test_contacts.py new file mode 100644 index 0000000..a6ddabe --- /dev/null +++ b/tests/test_contacts.py @@ -0,0 +1,113 @@ +# 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() |
