From 3eba0ecca47435f04e1558c5a7c60479f76cf973 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 9 Sep 2026 09:24:56 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz --- tests/test_contacts.py | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 tests/test_contacts.py (limited to 'tests') 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. +# +# 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() -- cgit v1.2.3