aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/cli.py92
-rw-r--r--docs/BACKLOG.md10
-rw-r--r--tests/test_cli.py200
3 files changed, 302 insertions, 0 deletions
diff --git a/abusectl/cli.py b/abusectl/cli.py
index 3065c34..299d3a1 100644
--- a/abusectl/cli.py
+++ b/abusectl/cli.py
@@ -35,6 +35,7 @@ from abusectl import contacts as contacts_module
from abusectl import init as init_module
from abusectl import parse as parse_module
from abusectl import rdap as rdap_module
+from abusectl import report as report_module
EXIT_OK = 0
EXIT_ERROR = 1
@@ -67,6 +68,16 @@ def _build_parser() -> argparse.ArgumentParser:
)
contacts_parser.add_argument("case", type=Path)
+ report_parser = subparsers.add_parser(
+ "report", help="build report bodies for a case"
+ )
+ report_parser.add_argument("case", type=Path)
+ report_parser.add_argument(
+ "--force",
+ action="store_true",
+ help="discard hand edits to bodies, keeping a timestamped backup",
+ )
+
return parser
@@ -369,6 +380,85 @@ def _cmd_contacts(args) -> int:
return EXIT_OK
+def _cmd_report(args) -> int:
+ """Build report bodies and rewrite the manifest.
+
+ Offline and irreversible-free: nothing here sends anything. The output is
+ what the user reviews before submit does something that cannot be
+ recalled, so the summary names the directory holding it. A count alone
+ tells a user something happened and not where to look, and until `submit`
+ exists reviewing those files by hand is the whole point of running this.
+
+ THE IDENTITY RULE IS: EMAIL REQUIRED, NAME AND ORG OPTIONAL. Each
+ [reporter] key is individually skippable at init and config drops a
+ skipped one rather than storing "", so a partial identity is a normal
+ shape rather than a broken one, and report.text_part() already renders
+ whatever subset is present. Requiring all three would refuse a config
+ `init` itself writes without complaint. The address is different in kind:
+ it becomes the From, so a report built without one either raises
+ KeyError out of build() (backlog item 5) or, worse, is sent with an
+ empty addr-spec and cannot be replied to, which defeats the reason the
+ identity is disclosed at all.
+
+ Checked HERE rather than in report.build(), for the reason backlog item 5
+ names: where the check lives decides whether the user gets an exit code
+ and a sentence or a traceback. It is the same not-configured condition
+ config.load() raises for a missing trust boundary, so it gets the same
+ exit code and the same pointer at `init`.
+
+ case.py stays the only writer of the manifest: generate() returns it and
+ case.save() writes it atomically. Bodies are report.py's own, and they
+ are written before that save, so a save that fails leaves bodies on disk
+ that the manifest does not record. That is ACCEPTABLE and needs no
+ machinery: the next run finds no recorded hash for them, treats them as
+ regenerable rather than hand-edited, and overwrites them. Nothing has
+ been sent, and a body whose hash nobody recorded is claimed by nobody.
+ """
+ config_path = args.config if args.config is not None else config.path()
+
+ try:
+ settings = config.load(config_path)
+ except config.NotConfigured as exc:
+ print(f"abusectl report: {exc}", file=sys.stderr)
+ return EXIT_NOT_CONFIGURED
+
+ identity = settings.reporter
+ if not identity.get("email"):
+ print(
+ "abusectl report: no reporter email configured, and a report "
+ "with no reply address is one an abuse desk cannot answer. "
+ f"Set email under [reporter] in {config_path}, or run "
+ "`abusectl init`.",
+ file=sys.stderr,
+ )
+ return EXIT_NOT_CONFIGURED
+
+ try:
+ manifest = case.load(args.case)
+ except FileNotFoundError:
+ print(f"abusectl report: no case at {args.case}", file=sys.stderr)
+ return EXIT_ERROR
+ except (ValueError, OSError) as exc:
+ print(f"abusectl report: {args.case}: {exc}", file=sys.stderr)
+ return EXIT_ERROR
+
+ try:
+ manifest = report_module.generate(
+ manifest, args.case, identity, force=args.force
+ )
+ except (report_module.Frozen, report_module.Modified) as exc:
+ print(f"abusectl report: {exc}", file=sys.stderr)
+ return EXIT_ERROR
+
+ case.save(args.case, manifest)
+
+ count = len(manifest["destinations"])
+ orphans = len(manifest["unreportable"])
+ print(f"{count} destinations, {orphans} indicators with no abuse desk")
+ print(f"bodies in {Path(args.case) / 'bodies'}; review before submitting")
+ return EXIT_OK
+
+
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
@@ -380,6 +470,8 @@ def main(argv: list[str] | None = None) -> int:
return _cmd_parse(args)
if args.command == "contacts":
return _cmd_contacts(args)
+ if args.command == "report":
+ return _cmd_report(args)
except KeyboardInterrupt:
# Ctrl+C, or EOF at a prompt. Setup writes nothing until every
# answer is in hand, so abandoning it leaves no half-written config.
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 63ea52c..3ab642f 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -9,6 +9,7 @@ number and gains a status rather than being renumbered.
| 2 | An IDN indicator resolves to no contact | S | open |
| 3 | Expose kept cases so qtmaildir can tag spam | ? | open, unsized |
| 4 | `Report-Type: phishing` is unverified against x-arf | XS | open |
+| 5 | `report.build()` raises KeyError on an identity with no email | XS | closed |
## 1. Skip boilerplate namespace URLs
@@ -137,6 +138,15 @@ expects.
## 5. `report.build()` raises KeyError on an identity with no email
+**Closed** by the `report` subcommand (Task 11). `cli._cmd_report()` refuses
+before building, with the not-configured exit code and a sentence naming the
+file to edit, rather than letting `build()` raise. The rule it applies is
+EMAIL REQUIRED, NAME AND ORG OPTIONAL: the address becomes the `From` and a
+report without one cannot be answered, while the other two are individually
+skippable at `init` and `text_part()` already renders whatever subset is
+present. `tests/test_cli.py` covers both halves, the refusal and the
+email-only identity that must still succeed.
+
**Observed.** `report.build(manifest, destination, identity)` reads
`identity.get("name", "")` defensively but `identity["email"]` directly, so an
identity carrying a name and no address raises `KeyError: 'email'` rather than
diff --git a/tests/test_cli.py b/tests/test_cli.py
index b8fffea..89da6b0 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -259,5 +259,205 @@ class ContactsCommand(unittest.TestCase):
self.assertEqual(code, cli.EXIT_ERROR)
+class ReportCommand(unittest.TestCase):
+ """The report command's dispatch: exit codes, and what it refuses.
+
+ The identity rule this asserts is EMAIL REQUIRED, NAME AND ORG OPTIONAL,
+ which is narrower than the plan's "all three". Every [reporter] key is
+ individually skippable by init and config drops a skipped one, so
+ demanding all three would refuse a config init itself is happy to write.
+ report.text_part() renders whatever subset is present, and build() puts
+ the address in the From, so only the address is load-bearing: a report
+ with no reply address is one an abuse desk cannot answer.
+ """
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.root = pathlib.Path(self._tmp.name)
+ self.config = self.root / "config.toml"
+
+ def tearDown(self):
+ self._tmp.cleanup()
+
+ def _write_config(self, reporter: str) -> None:
+ self.config.write_text(
+ '[general]\ntrusted_relays = ["192.0.2.0/24"]\n' + reporter,
+ encoding="utf-8",
+ )
+
+ def _make_case(self):
+ from abusectl import case
+
+ created = case.create(
+ self.root / "cases", b"From: sender@example.invalid\r\n\r\nbody\r\n"
+ )
+ manifest = case.load(created.path)
+ manifest["iocs"] = [
+ {"id": "ioc-1", "type": "ipv4", "value": "198.51.100.7",
+ "origin": "received-chain"}
+ ]
+ manifest["contacts"] = [{
+ "iocs": ["ioc-1"], "query": "198.51.100.7",
+ "abuse": ["abuse@example.invalid"], "source": "rdap",
+ }]
+ case.save(created.path, manifest)
+ return created.path
+
+ def _run(self, *args):
+ out, err = io.StringIO(), io.StringIO()
+ with redirect_stdout(out), redirect_stderr(err):
+ code = cli.main(list(args))
+ return code, out.getvalue(), err.getvalue()
+
+ def test_a_missing_case_is_an_error_not_a_traceback(self):
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ code, _, err = self._run(
+ "--config", str(self.config), "report", "/nonexistent/case"
+ )
+ self.assertEqual(code, cli.EXIT_ERROR)
+ self.assertIn("/nonexistent/case", err)
+
+ def test_a_case_with_no_reporter_configured_says_so(self):
+ # An unconfigured identity is not-configured, not a crash: the
+ # report would otherwise be filed with no reply address.
+ self._write_config("")
+ case_path = self._make_case()
+ code, _, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_NOT_CONFIGURED)
+ self.assertIn("abusectl init", err)
+ # Nothing was written. A refusal must leave the case as it was.
+ self.assertEqual(list((case_path / "bodies").iterdir()), [])
+
+ def test_an_identity_with_no_email_is_refused_rather_than_crashing(self):
+ # Backlog item 5: build() reads identity["email"] directly, so this
+ # shape raised KeyError. A name without an address is exactly what
+ # skipping one init prompt and answering another produces.
+ self._write_config('[reporter]\nname = "A Reporter"\n')
+ case_path = self._make_case()
+ code, _, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_NOT_CONFIGURED)
+ self.assertIn("email", err)
+ self.assertEqual(list((case_path / "bodies").iterdir()), [])
+
+ def test_an_email_alone_is_enough_to_build_a_report(self):
+ # name and org are genuinely optional, and text_part renders the
+ # subset that is present. Refusing here would reject a config init
+ # writes without complaint.
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ code, out, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_OK, err)
+
+ from abusectl import case
+
+ manifest = case.load(case_path)
+ self.assertEqual(len(manifest["destinations"]), 1)
+ body = case_path / manifest["destinations"][0]["body"]
+ text = body.read_bytes().decode("utf-8")
+ self.assertIn("From: r@example.org", text)
+ self.assertIn("Reported by: <r@example.org>", text)
+ # The summary tells the user where to look: nothing sends these yet,
+ # so review is the next step and it needs a path.
+ self.assertIn("1 destinations", out)
+ self.assertIn(str(case_path / "bodies"), out)
+
+ def test_the_global_config_option_is_honoured(self):
+ # --config is global and every other subcommand honours it. Calling
+ # config.load() with no argument would read the user's real config
+ # and report against the wrong identity, or refuse a configured run.
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ code, _, err = self._run(
+ "--config", str(self.root / "absent.toml"), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_NOT_CONFIGURED)
+ self.assertIn("absent.toml", err)
+
+ def test_a_frozen_case_is_an_error_not_a_traceback(self):
+ from abusectl import case
+
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ manifest = case.load(case_path)
+ manifest["frozen"] = {"by": "abuse@example.invalid", "at": "2026-09-10"}
+ case.save(case_path, manifest)
+
+ code, _, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_ERROR)
+ self.assertIn("cannot be regenerated", err)
+
+ def test_an_edited_body_is_refused_and_force_clears_it(self):
+ from abusectl import case
+
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ self._run("--config", str(self.config), "report", str(case_path))
+
+ body = case_path / case.load(case_path)["destinations"][0]["body"]
+ body.write_bytes(b"hand edited\n")
+
+ code, _, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_ERROR)
+ self.assertIn("--force", err)
+ self.assertEqual(body.read_bytes(), b"hand edited\n")
+
+ code, _, err = self._run(
+ "--config", str(self.config), "report", "--force", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_OK, err)
+ self.assertIn("From: r@example.org", body.read_bytes().decode("utf-8"))
+ backups = list((case_path / "bodies").glob("*.orig"))
+ self.assertEqual(len(backups), 1)
+ self.assertEqual(backups[0].read_bytes(), b"hand edited\n")
+
+ def test_a_second_run_on_an_untouched_case_succeeds(self):
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ self._run("--config", str(self.config), "report", str(case_path))
+ code, _, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_OK, err)
+
+ def test_a_corrupt_manifest_is_an_error_not_a_traceback(self):
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ (case_path / "manifest.json").write_text("{not json")
+ code, _, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_ERROR)
+
+ def test_a_case_with_no_contacts_reports_nothing_rather_than_failing(self):
+ # generate() always sets both keys, so the summary can index them.
+ from abusectl import case
+
+ self._write_config('[reporter]\nemail = "r@example.org"\n')
+ case_path = self._make_case()
+ manifest = case.load(case_path)
+ manifest["contacts"] = []
+ del manifest["destinations"]
+ case.save(case_path, manifest)
+
+ code, out, err = self._run(
+ "--config", str(self.config), "report", str(case_path)
+ )
+ self.assertEqual(code, cli.EXIT_OK, err)
+ self.assertIn("0 destinations", out)
+ written = case.load(case_path)
+ self.assertEqual(written["destinations"], [])
+ self.assertEqual(written["unreportable"], [])
+
+
if __name__ == "__main__":
unittest.main()