aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-10 16:56:10 +0200
committerDanilo M. <danix@danix.xyz>2026-09-10 16:56:10 +0200
commit9a13bd372a79d0c944d06801b1c3c67f8e8b8397 (patch)
tree7d6b2bfd37e202cae6969cefe39da77c2edd0fbb
parent59106867052157a78b825224500f974f985362de (diff)
downloadabusectl-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.py39
-rw-r--r--tests/test_init.py44
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()