diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-10 16:56:10 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-10 16:56:10 +0200 |
| commit | 9a13bd372a79d0c944d06801b1c3c67f8e8b8397 (patch) | |
| tree | 7d6b2bfd37e202cae6969cefe39da77c2edd0fbb | |
| parent | 59106867052157a78b825224500f974f985362de (diff) | |
| download | abusectl-9a13bd372a79d0c944d06801b1c3c67f8e8b8397.tar.gz abusectl-9a13bd372a79d0c944d06801b1c3c67f8e8b8397.zip | |
feat: render the destination sections at init
init.build() can now write [misp], [abusedb] and [urlhaus], the three
sections report reads, so a user no longer has to hand-edit the config
for them. Follows the [reporter] pattern: a section is emitted only
when something was answered for it, never with an empty api_key.
A half-answered pair (misp_url given, misp_api_key skipped, or vice
versa) is rejected rather than silently written: config.load() would
classify it "incomplete" and report would refuse, but only after the
user believed setup succeeded. Task 8's prompts are the normal route
in, but build() is pure and a caller can hand-build a dict, so this is
enforced here too.
Also fixes FOOTER, which still promised a single [vendors] table; the
spec settled three separate sections and [misp] is no longer "a later
part" since this plan builds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176FYdVfpzUq8S9jecqQqL6
| -rw-r--r-- | abusectl/init.py | 39 | ||||
| -rw-r--r-- | tests/test_init.py | 44 |
2 files changed, 81 insertions, 2 deletions
diff --git a/abusectl/init.py b/abusectl/init.py index c702bc7..dd6ef03 100644 --- a/abusectl/init.py +++ b/abusectl/init.py @@ -173,12 +173,19 @@ _REPORTER_FIELDS = ( FOOTER = "\n".join([ "", "# Later parts of abusectl add further sections here:", - "# [misp] - MISP instance URL and API key", - "# [vendors] - per-vendor threat-intel API keys", "# [reporting] - abuse-desk reporting defaults", ]) + "\n" +# Each destination's config keys and the answer key each is asked under. +# Rendered in this order so a written file is stable between runs. +_DESTINATION_FIELDS = ( + ("misp", (("url", "misp_url"), ("api_key", "misp_api_key"))), + ("abusedb", (("api_key", "abusedb_api_key"),)), + ("urlhaus", (("api_key", "urlhaus_api_key"),)), +) + + def build(answers: dict) -> str: """Render answers as config TOML for the [general] and [reporter] tables.""" relays = _validate_relays(answers.get("trusted_relays", [])) @@ -225,6 +232,34 @@ def build(answers: dict) -> str: ]) lines.extend(reporter) + # A section is emitted only when something was answered for it, the same + # rule [reporter] follows: an empty [abusedb] reads as + # configured-with-nothing, which is the api_key = "" trap wearing a + # different coat. + for section, fields in _DESTINATION_FIELDS: + rendered = [] + answered_keys = [] + for key, answer_key in fields: + value = answers.get(answer_key, "") + if isinstance(value, str) and value.strip(): + rendered.append(f"{key} = {_quoted(value.strip(), key)}") + answered_keys.append(key) + # A half-answered pair (e.g. misp_url given, misp_api_key skipped) + # is worse than either extreme: config.load() classifies it + # "incomplete" and report refuses, but only after the user believes + # setup succeeded. build() is pure and Task 8's prompts are the + # normal route in, but a caller can still hand-build a dict, so this + # is caught here rather than left to the prompt layer alone. + if rendered and len(answered_keys) != len(fields): + missing = [key for key, _ in fields if key not in answered_keys] + raise ValueError( + f"[{section}] needs {', '.join(k for k, _ in fields)}; " + f"missing {', '.join(missing)}" + ) + if rendered: + lines.extend(["", f"[{section}]"]) + lines.extend(rendered) + return "\n".join(lines) + "\n" + FOOTER diff --git a/tests/test_init.py b/tests/test_init.py index f1b20f8..798ed26 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -319,6 +319,50 @@ class TestWriteGuard(unittest.TestCase): self.assertNotIn("topsecret", summary) +class BuildsDestinationSections(unittest.TestCase): + ANSWERS = {"trusted_relays": ["192.0.2.0/24"]} + + def test_a_misp_pair_is_rendered(self): + text = init.build({**self.ANSWERS, + "misp_url": "https://misp.example.invalid", + "misp_api_key": "k"}) + data = tomllib.loads(text) + self.assertEqual(data["misp"]["url"], "https://misp.example.invalid") + self.assertEqual(data["misp"]["api_key"], "k") + + def test_a_vendor_key_is_rendered(self): + text = init.build({**self.ANSWERS, "abusedb_api_key": "k"}) + self.assertEqual(tomllib.loads(text)["abusedb"]["api_key"], "k") + + def test_a_skipped_destination_is_absent_not_empty(self): + # api_key = "" reads as configured-and-broken. Absent reads as + # not-configured and report can say so plainly. + text = init.build({**self.ANSWERS, "urlhaus_api_key": ""}) + self.assertNotIn("[urlhaus]", text) + self.assertNotIn("urlhaus", tomllib.loads(text)) + + def test_nothing_answered_renders_no_destination_section(self): + data = tomllib.loads(init.build(self.ANSWERS)) + for name in ("misp", "abusedb", "urlhaus"): + self.assertNotIn(name, data) + + def test_the_footer_no_longer_promises_a_vendors_table(self): + # It named [vendors] as one table; the spec settles three sections, + # and a footer describing a shape that never arrives is worse than + # no footer. + self.assertNotIn("[vendors]", init.build(self.ANSWERS)) + + def test_every_rendered_section_parses(self): + text = init.build({**self.ANSWERS, + "misp_url": "https://misp.example.invalid", + "misp_api_key": "k", + "abusedb_api_key": "a", + "urlhaus_api_key": "u"}) + data = tomllib.loads(text) + self.assertEqual(set(data) & {"misp", "abusedb", "urlhaus"}, + {"misp", "abusedb", "urlhaus"}) + + if __name__ == "__main__": unittest.main() |
