diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 16:49:33 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 16:49:33 +0200 |
| commit | 6593d2596f5d3df0da60a4e083b8097c8539b14e (patch) | |
| tree | 06ba4eb3de22d3cd46e369a34b84a13a7ce9736b | |
| parent | 88d07d352e84b0c32012c6989e8402980e0a3b94 (diff) | |
| download | abusectl-6593d2596f5d3df0da60a4e083b8097c8539b14e.tar.gz abusectl-6593d2596f5d3df0da60a4e083b8097c8539b14e.zip | |
feat: write vendor rows into the manifest
generate() gains a `configured` argument and appends vendor_destinations()
after the email bodies are written. It is an ARGUMENT rather than a config
read, the way the identity already is: report.py stays pure and testable
with no files on disk. Defaulting to nothing configured is the safe
direction, a caller that forgets it writes no vendor row rather than
promising a submission nobody set up.
cli.py passes settings.destinations down, so a config carrying a vendor's
keys produces that vendor's row in the case manifest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176FYdVfpzUq8S9jecqQqL6
| -rw-r--r-- | abusectl/cli.py | 3 | ||||
| -rw-r--r-- | abusectl/report.py | 23 | ||||
| -rw-r--r-- | tests/test_report.py | 79 |
3 files changed, 103 insertions, 2 deletions
diff --git a/abusectl/cli.py b/abusectl/cli.py index 299d3a1..c467f9a 100644 --- a/abusectl/cli.py +++ b/abusectl/cli.py @@ -444,7 +444,8 @@ def _cmd_report(args) -> int: try: manifest = report_module.generate( - manifest, args.case, identity, force=args.force + manifest, args.case, identity, force=args.force, + configured=settings.destinations, ) except (report_module.Frozen, report_module.Modified) as exc: print(f"abusectl report: {exc}", file=sys.stderr) diff --git a/abusectl/report.py b/abusectl/report.py index 9897cc8..33bb34f 100644 --- a/abusectl/report.py +++ b/abusectl/report.py @@ -1085,7 +1085,7 @@ def _write_body(path: Path, text: str) -> None: def generate(manifest: dict, case_path: Path, identity: dict, - force: bool = False) -> dict: + force: bool = False, configured: set | None = None) -> dict: """Write every body and return the manifest with destinations[] set. The manifest is RETURNED rather than saved: case.py is the only writer @@ -1104,6 +1104,24 @@ def generate(manifest: dict, case_path: Path, identity: dict, a desk already holds, and a half-rewritten one is worse than a stale one. Scanning every destination before refusing is also what makes the error name every edited body rather than the first. + + `configured` is the set of destinations the config carries keys for, an + ARGUMENT rather than a config read, so this module stays pure and + testable with no files on disk. Defaulting to nothing configured is the + safe direction: a caller that forgets it writes no vendor row rather + than promising a submission nobody set up. + + destinations[] is rebuilt WHOLESALE every run, vendor rows included. + That is safe only while the freeze rule holds: once a destination has + landed the case is frozen and this function refuses to run at all. The + freeze marker is written by submit, so this is a constraint on that + spec, not merely a description of this one. + + Vendor rows are appended AFTER the body-writing loop, and that ordering + is load-bearing rather than incidental: the loop mutates the dicts it + walks, so a vendor row inside it would be handed a body it has no + payload for. A vendor row's body stays null until submit settles each + endpoint's payload shape. """ case_path = Path(case_path) bodies = case_path / "bodies" @@ -1143,6 +1161,9 @@ def generate(manifest: dict, case_path: Path, identity: dict, destination["body"] = relative destination["body_sha256"] = body_hash(text) + destinations.extend( + vendor_destinations(manifest.get("iocs", []), configured or set()) + ) manifest["destinations"] = destinations manifest["unreportable"] = unreportable(manifest.get("contacts", [])) return manifest diff --git a/tests/test_report.py b/tests/test_report.py index d0e8239..c0fe0b0 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -2106,3 +2106,82 @@ class VendorDestinations(unittest.TestCase): [row["id"] for row in second]) self.assertEqual([row["id"] for row in first], ["misp", "abusedb", "urlhaus"]) + + +class GenerateWritesVendorRows(unittest.TestCase): + """The seam: vendor rows land in the manifest beside the email rows. + + generate() takes `configured` as an ARGUMENT rather than reading the + config, the way it already takes the identity and parse.py takes the + trust boundary. That is what keeps this module testable with no files + on disk beyond the case directory it writes into. + """ + + def _manifest(self): + return { + "format": 1, + "case_id": "2026-09-10-test", + "headers": [["From", "phish@example.invalid"], + ["Subject", "test"], + ["Date", "Wed, 09 Sep 2026 09:12:44 +0000"]], + "iocs": [ + {"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7"}, + {"id": "ioc-2", "type": "url", + "value": "http://example.invalid/a"}, + ], + "contacts": [{"ioc": "ioc-1", "iocs": ["ioc-1"], + "abuse": ["abuse@example.invalid"]}], + } + + def test_configured_destinations_appear_alongside_the_email_rows(self): + with tempfile.TemporaryDirectory() as tmp: + out = report.generate(self._manifest(), Path(tmp), + {"email": "r@example.org"}, + configured={"misp", "abusedb"}) + kinds = [row["kind"] for row in out["destinations"]] + self.assertIn("email", kinds) + self.assertIn("misp", kinds) + self.assertIn("api", kinds) + + def test_nothing_configured_leaves_the_email_rows_untouched(self): + with tempfile.TemporaryDirectory() as tmp: + out = report.generate(self._manifest(), Path(tmp), + {"email": "r@example.org"}, + configured=set()) + self.assertEqual([row["kind"] for row in out["destinations"]], + ["email"]) + + def test_a_second_run_produces_the_same_rows(self): + with tempfile.TemporaryDirectory() as tmp: + first = report.generate(self._manifest(), Path(tmp), + {"email": "r@example.org"}, + configured={"misp"}) + ids = [row["id"] for row in first["destinations"]] + second = report.generate(first, Path(tmp), + {"email": "r@example.org"}, + configured={"misp"}) + self.assertEqual([row["id"] for row in second["destinations"]], ids) + + def test_vendor_rows_get_no_body_written_to_disk(self): + with tempfile.TemporaryDirectory() as tmp: + out = report.generate(self._manifest(), Path(tmp), + {"email": "r@example.org"}, + configured={"misp", "abusedb"}) + written = sorted(p.name + for p in (Path(tmp) / "bodies").iterdir()) + self.assertTrue(all(name.endswith(".xarf") for name in written)) + self.assertEqual(len(written), 1) + for row in out["destinations"]: + if row["kind"] != "email": + self.assertIsNone(row["body"]) + self.assertNotIn("body_sha256", row) + + def test_configured_defaults_to_nothing(self): + # An older caller that does not pass it must not crash, and must not + # silently gain destinations it never configured. + with tempfile.TemporaryDirectory() as tmp: + out = report.generate(self._manifest(), Path(tmp), + {"email": "r@example.org"}) + self.assertEqual([row["kind"] for row in out["destinations"]], + ["email"]) + |
