diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 16:53:54 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 16:53:54 +0200 |
| commit | 59106867052157a78b825224500f974f985362de (patch) | |
| tree | c1caaa8eecb1326c28a9d654b9f127a2a31e58c6 | |
| parent | 9fce523fd196d7ab71c2b54eeb02071178506045 (diff) | |
| download | abusectl-59106867052157a78b825224500f974f985362de.tar.gz abusectl-59106867052157a78b825224500f974f985362de.zip | |
feat: refuse a half-configured destination at report
A [misp] section with url but no api_key (or vice versa) is
configured-and-broken, not skipped. report now refuses it before
writing anything, naming the missing key, while parse and contacts
stay unaffected since neither reads config.load()'s incomplete set.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176FYdVfpzUq8S9jecqQqL6
| -rw-r--r-- | abusectl/cli.py | 16 | ||||
| -rw-r--r-- | tests/test_cli.py | 46 |
2 files changed, 62 insertions, 0 deletions
diff --git a/abusectl/cli.py b/abusectl/cli.py index 2b4e1d7..636bd4e 100644 --- a/abusectl/cli.py +++ b/abusectl/cli.py @@ -433,6 +433,22 @@ def _cmd_report(args) -> int: ) return EXIT_NOT_CONFIGURED + # Refused HERE rather than in config.load(), for the reason backlog + # item 5 already records one level up: an answer reported somewhere + # other than where it is used. Every subcommand loads this file, so + # raising in the reader would make `parse` refuse over a section it + # never reads, and cli catches only NotConfigured, so the user would + # get a traceback rather than a sentence. + if settings.incomplete: + for name, missing in sorted(settings.incomplete.items()): + print( + f"abusectl report: [{name}] in {config_path} is missing " + f"{missing}, so it is configured-and-broken rather than " + "skipped. Add it, or remove the section entirely.", + file=sys.stderr, + ) + return EXIT_NOT_CONFIGURED + # Warned, not refused: a case with abuse-desk destinations and no # vendors is a perfectly good report, and this tool is specified to be # useful with no API key configured anywhere. Not silent either. A user diff --git a/tests/test_cli.py b/tests/test_cli.py index 3638edd..937a0b7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -480,6 +480,52 @@ class ReportCommand(unittest.TestCase): self.assertEqual(code, 0) self.assertNotIn("no reporting destinations", err) + def test_a_misp_url_with_no_key_is_refused_naming_the_missing_key(self): + self._write_config( + '[reporter]\nemail = "r@example.org"\n' + '\n[misp]\nurl = "https://misp.example.invalid"\n' + ) + case_path = self._make_case() + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, 3) + self.assertIn("api_key", err) + self.assertIn(str(self.config), err) + # A refusal must leave the case as it was: nothing regenerated. + self.assertEqual(list((case_path / "bodies").iterdir()), []) + + def test_half_a_pair_does_not_stop_parse(self): + # The half that would regress silently. Every subcommand loads this + # file; parse reads neither MISP key and must not be refused over + # one, nor traceback, since cli catches only NotConfigured. + self.config.write_text( + '[general]\ntrusted_relays = ["192.0.2.0/24"]\n' + f'cases = "{self.root / "cases"}"\n' + '\n[misp]\napi_key = "k"\n', + encoding="utf-8", + ) + code, _, _ = self._run( + "--config", str(self.config), "parse", + str(FIXTURES / "forged-chain.eml"), + ) + self.assertEqual(code, 0) + + def test_an_abusedb_section_with_no_key_is_a_skip_not_a_refusal(self): + # [abusedb] needs only one key, so it can never be half-filled: an + # empty section is absence of the whole thing, which is a skip and + # should reach the no-destinations warning, not the refusal. + self._write_config( + '[reporter]\nemail = "r@example.org"\n' + '\n[abusedb]\n' + ) + case_path = self._make_case() + code, _, err = self._run( + "--config", str(self.config), "report", str(case_path) + ) + self.assertEqual(code, 0) + self.assertIn("no reporting destinations", err) + if __name__ == "__main__": unittest.main() |
