aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 16:49:33 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 16:49:33 +0200
commit6593d2596f5d3df0da60a4e083b8097c8539b14e (patch)
tree06ba4eb3de22d3cd46e369a34b84a13a7ce9736b
parent88d07d352e84b0c32012c6989e8402980e0a3b94 (diff)
downloadabusectl-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.py3
-rw-r--r--abusectl/report.py23
-rw-r--r--tests/test_report.py79
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"])
+