aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/report.py98
-rw-r--r--tests/test_report.py224
2 files changed, 322 insertions, 0 deletions
diff --git a/abusectl/report.py b/abusectl/report.py
index 38a50ff..20eb58a 100644
--- a/abusectl/report.py
+++ b/abusectl/report.py
@@ -28,10 +28,12 @@ itself is a module that can disclose it in a code path nobody reviewed.
import hashlib
+from datetime import datetime, timezone
from email.header import Header
from email.headerregistry import Address
from email.message import EmailMessage
from email.policy import SMTP
+from pathlib import Path
VERSION = "0.1.0"
@@ -954,3 +956,99 @@ def check_regenerable(manifest: dict, modified: list[str],
". Re-run with --force to discard those edits; a timestamped "
"backup is kept."
)
+
+
+def _read_body(path: Path) -> str:
+ """Read a body back EXACTLY as it was written.
+
+ Deliberately not read_text(). A body is an RFC 5322 document delimited
+ by CRLF, while the feedback part inside it carries bare LFs that
+ set_content chose; text mode applies universal newlines and collapses
+ every CRLF to LF on the way in. The text that comes back then hashes
+ differently from the text that was written even though not one byte on
+ disk changed, so an untouched case reads as hand-edited and refuses to
+ regenerate, and for a sent destination the recorded hash is a false
+ record of what was disclosed.
+
+ The encoding is pinned to utf-8 for the same reason body_hash pins it:
+ the locale of the run that reads a body is not the locale of the run
+ that wrote it.
+ """
+ return path.read_bytes().decode("utf-8")
+
+
+def _write_body(path: Path, text: str) -> None:
+ """Write a body byte-exactly, in the encoding body_hash assumes.
+
+ Binary rather than write_text for the mirror of the reason above: text
+ mode translates a bare LF to os.linesep, which on a CRLF platform
+ would rewrite the bare LFs inside the machine-readable part and
+ corrupt the field block a desk's parser reads. The bytes written here
+ are the bytes hashed, which is what makes the hash a record of what
+ was disclosed rather than of what was intended.
+ """
+ path.write_bytes(text.encode("utf-8"))
+
+
+def generate(manifest: dict, case_path: Path, identity: dict,
+ force: bool = False) -> dict:
+ """Write every body and return the manifest with destinations[] set.
+
+ The manifest is RETURNED rather than saved: case.py is the only writer
+ of a case directory's manifest, so the caller saves. The bodies are
+ this module's to write because they are not the manifest.
+
+ The case directory must already exist. `bodies` is created if it is
+ missing, but its parents are NOT, because a missing case directory
+ means a wrong path rather than a first run and creating the tree would
+ scatter bodies into an empty directory the user never made.
+
+ ORDER MATTERS AND IS THE POINT. The whole manifest is scanned for
+ edited bodies first, then check_regenerable decides, and only then does
+ anything on disk change. A refusal therefore leaves the bodies
+ directory byte-for-byte as it was: a frozen case is an evidence record
+ 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.
+ """
+ case_path = Path(case_path)
+ bodies = case_path / "bodies"
+ bodies.mkdir(exist_ok=True)
+
+ modified = []
+ for existing in manifest.get("destinations") or []:
+ recorded = existing.get("body_sha256")
+ if not recorded or not existing.get("body"):
+ continue
+ path = case_path / existing["body"]
+ # A deleted body is not a modified one. It regenerates silently,
+ # because deleting a body is how a user asks for a fresh one.
+ if path.exists() and body_hash(_read_body(path)) != recorded:
+ modified.append(existing["id"])
+
+ check_regenerable(manifest, modified, force=force)
+
+ stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ for destination_id in modified:
+ existing = next(d for d in manifest["destinations"]
+ if d["id"] == destination_id)
+ path = case_path / existing["body"]
+ # The stamp goes AFTER the full name rather than replacing the
+ # extension, so the destination id stays legible in a directory
+ # listing and two forced runs in different seconds keep both
+ # edits. with_suffix would be wrong here: it replaces the last
+ # suffix, so it depends on the name having exactly the one this
+ # writer gave it.
+ path.rename(path.with_name(f"{path.name}.{stamp}.orig"))
+
+ destinations = email_destinations(manifest.get("contacts", []))
+ for destination in destinations:
+ text = build(manifest, destination, identity)
+ relative = f"bodies/{destination['id']}.xarf"
+ _write_body(case_path / relative, text)
+ destination["body"] = relative
+ destination["body_sha256"] = body_hash(text)
+
+ manifest["destinations"] = destinations
+ manifest["unreportable"] = unreportable(manifest.get("contacts", []))
+ return manifest
diff --git a/tests/test_report.py b/tests/test_report.py
index b36252c..e87956f 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -3,7 +3,10 @@ import email
import email.policy
import hashlib
import json
+import re
+import tempfile
import unittest
+from pathlib import Path
from abusectl import report
@@ -1706,5 +1709,226 @@ class Hashes(unittest.TestCase):
self.assertEqual(len(report.body_hash(text)), 64)
+class Writing(unittest.TestCase):
+ """generate() writing bodies into a case directory.
+
+ A body is a byte-exact RFC 5322 document, not a text file the platform
+ may reflow, which is what most of these tests are really about.
+ """
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.path = Path(self.tmp.name)
+ (self.path / "bodies").mkdir()
+ self.addCleanup(self.tmp.cleanup)
+
+ def _generate(self, manifest=None, **kwargs):
+ if manifest is None:
+ manifest = copy.deepcopy(MANIFEST)
+ return report.generate(manifest, self.path, IDENTITY, **kwargs)
+
+ def test_it_writes_one_body_per_destination_and_records_the_hash(self):
+ manifest = self._generate()
+ destination = manifest["destinations"][0]
+ body = self.path / destination["body"]
+ self.assertTrue(body.exists())
+ self.assertEqual(
+ destination["body_sha256"],
+ report.body_hash(body.read_bytes().decode("utf-8")),
+ )
+
+ def test_the_body_path_is_named_for_the_destination_id(self):
+ """The id names the file, because the edit check pairs the two."""
+ manifest = self._generate()
+ destination = manifest["destinations"][0]
+ self.assertEqual(destination["body"],
+ f"bodies/{destination['id']}.xarf")
+
+ def test_the_recorded_hash_is_the_hash_of_the_bytes_on_disk(self):
+ """The round trip must be byte-exact, and this is the test that
+ says so for every destination rather than the first.
+
+ A report is CRLF-delimited per RFC 5322 while its feedback part
+ carries bare LFs, so a text-mode write can translate the bare LFs
+ and a text-mode read collapses every CRLF. Either one makes the
+ recorded hash disagree with the file, which reads as a hand edit
+ and, for a sent destination, is a false record of what was
+ disclosed.
+ """
+ manifest = self._generate()
+ for destination in manifest["destinations"]:
+ with self.subTest(destination=destination["id"]):
+ raw = (self.path / destination["body"]).read_bytes()
+ self.assertEqual(destination["body_sha256"],
+ hashlib.sha256(raw).hexdigest())
+
+ def test_the_written_body_keeps_its_crlf_line_endings(self):
+ """Verified against the document, not assumed from the policy."""
+ manifest = self._generate()
+ raw = (self.path / manifest["destinations"][0]["body"]).read_bytes()
+ self.assertIn(b"\r\n", raw)
+ # The header block is pure CRLF: a bare LF there would mean the
+ # write translated line endings, which is the corruption a
+ # text-mode write causes on a platform whose linesep is CRLF.
+ header, _, _ = raw.partition(b"\r\n\r\n")
+ self.assertNotIn(b"\n", header.replace(b"\r\n", b""))
+ # And the bare LFs inside the feedback part survive untranslated,
+ # which is the same defect seen from the other side.
+ self.assertIn(b"Feedback-Type: abuse\nUser-Agent:", raw)
+
+ def test_an_untouched_case_regenerates_without_complaint(self):
+ """The regression that the plan's read_text() caused.
+
+ Nothing was edited, so a second run must simply rewrite the
+ bodies. Under a lossy round trip every body reads as hand-edited
+ and the second run refuses, which makes report unusable twice.
+ """
+ manifest = self._generate()
+ again = report.generate(copy.deepcopy(manifest), self.path, IDENTITY)
+ self.assertEqual([d["id"] for d in again["destinations"]],
+ [d["id"] for d in manifest["destinations"]])
+ self.assertEqual(list((self.path / "bodies").glob("*.orig")), [])
+
+ def test_it_records_the_unreportable_indicators(self):
+ manifest = copy.deepcopy(MANIFEST)
+ manifest["contacts"] = manifest["contacts"] + [
+ {"iocs": ["ioc-4"], "query": "x.invalid", "abuse": [],
+ "source": "rdap", "error": "no abuse role published"},
+ ]
+ result = self._generate(manifest)
+ self.assertEqual(
+ result["unreportable"],
+ [{"ioc": "ioc-4", "reason": "no abuse role published"}],
+ )
+
+ def test_a_modified_body_is_backed_up_before_being_overwritten(self):
+ manifest = self._generate()
+ destination_id = manifest["destinations"][0]["id"]
+ body = self.path / manifest["destinations"][0]["body"]
+ body.write_bytes(b"hand edited during review\r\n")
+
+ with self.assertRaises(report.Modified):
+ report.generate(copy.deepcopy(manifest), self.path, IDENTITY)
+
+ report.generate(copy.deepcopy(manifest), self.path, IDENTITY,
+ force=True)
+ backups = list((self.path / "bodies").glob("*.orig"))
+ self.assertEqual(len(backups), 1)
+ # Pin the NAME, not just the count: a backup written beside the
+ # wrong destination still globs as one file. The stamp sits
+ # between the extension and .orig so the id stays readable.
+ self.assertTrue(
+ re.fullmatch(rf"{destination_id}\.xarf\.\d{{8}}T\d{{6}}Z\.orig",
+ backups[0].name),
+ backups[0].name,
+ )
+ self.assertEqual(backups[0].read_bytes(),
+ b"hand edited during review\r\n")
+ # And the body itself was replaced rather than left as the edit.
+ self.assertNotEqual(body.read_bytes(),
+ b"hand edited during review\r\n")
+
+ def test_a_backup_preserves_the_edited_bytes_exactly(self):
+ """The backup is the user's twenty minutes of review, so it is
+ copied verbatim rather than through a text-mode round trip.
+ """
+ manifest = self._generate()
+ body = self.path / manifest["destinations"][0]["body"]
+ edited = "line one\r\nline two\nnon-ascii: Örg\r\n"
+ body.write_bytes(edited.encode("utf-8"))
+ report.generate(copy.deepcopy(manifest), self.path, IDENTITY,
+ force=True)
+ backups = list((self.path / "bodies").glob("*.orig"))
+ self.assertEqual(backups[0].read_bytes(), edited.encode("utf-8"))
+
+ def test_a_deleted_body_regenerates_without_complaint(self):
+ manifest = self._generate()
+ (self.path / manifest["destinations"][0]["body"]).unlink()
+ again = report.generate(copy.deepcopy(manifest), self.path, IDENTITY)
+ self.assertTrue((self.path / again["destinations"][0]["body"]).exists())
+
+ def test_a_frozen_case_writes_nothing_at_all(self):
+ """A refusal must leave the bodies directory exactly as it was.
+
+ check_regenerable runs after the scan and before every write, so
+ no body is rewritten and no backup is renamed. Asserting the
+ directory's full state is what makes that ordering testable: a
+ partial write on a frozen case corrupts an evidence record a desk
+ already holds.
+ """
+ manifest = self._generate()
+ before = {p.name: p.read_bytes()
+ for p in (self.path / "bodies").iterdir()}
+ manifest["frozen"] = {"at": "2026-09-07T10:00:00Z", "by": "abusedb"}
+
+ with self.assertRaises(report.Frozen):
+ report.generate(copy.deepcopy(manifest), self.path, IDENTITY)
+ with self.assertRaises(report.Frozen):
+ report.generate(copy.deepcopy(manifest), self.path, IDENTITY,
+ force=True)
+
+ after = {p.name: p.read_bytes()
+ for p in (self.path / "bodies").iterdir()}
+ self.assertEqual(after, before)
+
+ def test_a_modified_refusal_writes_nothing_at_all(self):
+ """Same ordering, the other refusal. Without --force an edited
+ body must survive untouched, including every body that was NOT
+ edited: a partial rewrite would discard part of a review.
+ """
+ manifest = self._generate()
+ body = self.path / manifest["destinations"][0]["body"]
+ body.write_bytes(b"hand edited during review\r\n")
+ before = {p.name: p.read_bytes()
+ for p in (self.path / "bodies").iterdir()}
+
+ with self.assertRaises(report.Modified):
+ report.generate(copy.deepcopy(manifest), self.path, IDENTITY)
+
+ after = {p.name: p.read_bytes()
+ for p in (self.path / "bodies").iterdir()}
+ self.assertEqual(after, before)
+
+ def test_it_refuses_a_case_directory_that_does_not_exist(self):
+ """generate does not conjure a case. case.py creates a case, and a
+ missing directory means a wrong path rather than a first run, so
+ creating the tree here would scatter bodies into an empty dir.
+ """
+ with self.assertRaises(FileNotFoundError):
+ report.generate(copy.deepcopy(MANIFEST), self.path / "absent",
+ IDENTITY)
+
+ def test_no_recipient_identifier_reaches_a_written_body(self):
+ """Property 1, checked against the bytes that land on disk.
+
+ The manifest is given a recipient address in every place a body
+ draws from, and none of it may appear in a file.
+ """
+ manifest = copy.deepcopy(MANIFEST)
+ secret = "victim@example.org"
+ manifest["headers"] = manifest["headers"] + [
+ ("To", f"Victim <{secret}>"),
+ ("Delivered-To", secret),
+ ]
+ manifest["iocs"] = manifest["iocs"] + [
+ {"id": "ioc-3", "type": "url",
+ "value": f"http://kit.invalid/verify?e={secret}",
+ "origin": "body"},
+ ]
+ result = self._generate(manifest)
+ for destination in result["destinations"]:
+ raw = (self.path / destination["body"]).read_bytes()
+ with self.subTest(destination=destination["id"]):
+ self.assertNotIn(secret.encode("utf-8"), raw)
+ self.assertNotIn(b"victim", raw.lower())
+
+ def test_it_returns_the_manifest_rather_than_saving_it(self):
+ """case.py is the only writer of a manifest."""
+ result = self._generate()
+ self.assertNotIn("manifest.json",
+ [p.name for p in self.path.iterdir()])
+ self.assertIn("destinations", result)
+
+
if __name__ == "__main__":
unittest.main()