aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--abusectl/config.py60
-rw-r--r--abusectl/report.py11
-rw-r--r--tests/test_config.py170
-rw-r--r--tests/test_report.py26
4 files changed, 260 insertions, 7 deletions
diff --git a/abusectl/config.py b/abusectl/config.py
index 38f5254..26c45f1 100644
--- a/abusectl/config.py
+++ b/abusectl/config.py
@@ -18,8 +18,13 @@ A key that was skipped at setup is ABSENT from the file, never an empty
string: api_key = "" reads as configured-and-broken and produces a confusing
auth error much later, while an absent key reads as not-configured and the
part that wants it can say so plainly. Later parts of this tool add MISP and
-vendor API keys under their own tables in this same file; this module only
-reads [general] and leaves other sections alone.
+vendor API keys under their own tables in this same file; this module reads
+[general] and [reporter] and leaves other sections alone.
+
+[reporter] holds the one identifier this tool discloses DELIBERATELY. The
+distinction from every identifier it refuses to publish is provenance, so it
+is enforced by provenance: the identity comes from config only and parse
+never supplies it.
Not-configured is a distinct condition, not a crash. This parser refuses to
guess which mail relays are the user's own, because guessing wrong means
@@ -32,7 +37,7 @@ import ipaddress
import os
import pathlib
import tomllib
-from dataclasses import dataclass
+from dataclasses import dataclass, field
class NotConfigured(Exception):
@@ -57,10 +62,22 @@ def path() -> pathlib.Path:
return pathlib.Path(base) / "abusectl" / "config.toml"
+# The reporting identity, and nothing else, is carried out of [reporter].
+# Only these three keys are kept: they are what the spec names and what a
+# report body consumes, so an unknown key cannot reach a document sent to a
+# third party by way of a config the user hand-edited.
+REPORTER_KEYS = ("name", "org", "email")
+
+
@dataclass(frozen=True)
class Config:
trusted_relays: list[str]
cases: pathlib.Path
+ # Empty when no identity is configured. A dict in a frozen dataclass is
+ # still mutable, and that is left alone deliberately: report.build()
+ # only reads it, and a defensive copy here would buy nothing while
+ # suggesting a guarantee this class does not make.
+ reporter: dict = field(default_factory=dict)
def load(from_path: pathlib.Path | None = None) -> Config:
@@ -101,4 +118,39 @@ def load(from_path: pathlib.Path | None = None) -> Config:
raw_cases = general.get("cases", "")
cases = pathlib.Path(raw_cases).expanduser() if raw_cases.strip() else DEFAULT_CASES
- return Config(trusted_relays=trusted_relays, cases=cases)
+ raw_reporter = data.get("reporter", {})
+ # reporter = "A Reporter" is valid TOML, and .items() on it raises
+ # AttributeError naming nothing the user can find in their file. Same
+ # shape of hand-edit as the unbracketed trusted_relays above.
+ if not isinstance(raw_reporter, dict):
+ raise ValueError(
+ f"reporter in {source} must be a table, got "
+ f"{type(raw_reporter).__name__}"
+ )
+
+ reporter = {}
+ for key in REPORTER_KEYS:
+ if key not in raw_reporter:
+ continue
+ value = raw_reporter[key]
+ # Rejected, not dropped. ipaddress.ip_network(42) returning a
+ # valid-looking network is the trap this file already learned from:
+ # a wrong value that reads as plausible is worse than an error. Here
+ # a dropped value reads as not-configured, and this is the ONE
+ # identifier the tool discloses deliberately, so a typo that
+ # silently removes the reply address from every report must be
+ # reported against the file that holds it.
+ if not isinstance(value, str):
+ raise ValueError(
+ f"reporter.{key} in {source} must be a string, got "
+ f"{type(value).__name__}: {value!r}"
+ )
+ # A skipped answer is ABSENT, never an empty string: "" reads as
+ # configured-and-broken much later, absent reads as not-configured
+ # and the part that wants it can say so plainly. Stored stripped
+ # because the name becomes a From display name, where surrounding
+ # whitespace survives verbatim into the header.
+ if value.strip():
+ reporter[key] = value.strip()
+
+ return Config(trusted_relays=trusted_relays, cases=cases, reporter=reporter)
diff --git a/abusectl/report.py b/abusectl/report.py
index 20eb58a..6bf3c54 100644
--- a/abusectl/report.py
+++ b/abusectl/report.py
@@ -529,9 +529,16 @@ def text_part(manifest: dict, destination: dict, identity: dict) -> str:
for key, value in sorted(auth.items())), " ")
lines += ["", *_REDACTION_NOTE, ""]
+ # Each [reporter] key is individually skippable and config DROPS a
+ # skipped one rather than storing "", so a partial identity is the
+ # normal shape here. Subscripting raised KeyError on a case that had
+ # parsed perfectly, and joining unconditionally left a stray comma with
+ # nothing on one side of it.
+ who = ", ".join(str(identity[key]) for key in ("name", "org")
+ if identity.get(key))
lines += _wrap_value(
- f"Reported by: {identity['name']}, {identity['org']} "
- f"<{identity['email']}>", "")
+ f"Reported by: {who + ' ' if who else ''}"
+ f"<{identity.get('email', '')}>", "")
lines.append("Generated by abusectl.")
return "\n".join(lines) + "\n"
diff --git a/tests/test_config.py b/tests/test_config.py
index 6fa8619..1815993 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -20,7 +20,25 @@ import tempfile
import unittest
from unittest import mock
-from abusectl import config
+from abusectl import config, report
+
+# Minimal, kept local rather than imported from test_report: these tests are
+# about the shape config produces, and they must not start failing because a
+# report fixture grew a field.
+_MANIFEST = {
+ "format": 1,
+ "case_id": "2026-09-07-aaaa",
+ "iocs": [
+ {"id": "ioc-1", "type": "ipv4", "value": "203.0.113.42",
+ "origin": "received-chain", "confidence": "boundary-hop"},
+ ],
+ "headers": [("From", '"Example Bank" <phish@sender.invalid>')],
+ "contacts": [
+ {"iocs": ["ioc-1"], "query": "203.0.113.42",
+ "abuse": ["abuse@host.invalid"], "source": "rdap"},
+ ],
+}
+_DESTINATION = {"id": "d1", "target": "abuse@host.invalid", "iocs": ["ioc-1"]}
class TestConfig(unittest.TestCase):
@@ -105,6 +123,156 @@ class TestConfig(unittest.TestCase):
with self.assertRaises(ValueError):
config.load(path)
+ def test_the_reporter_identity_is_read(self):
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'name = "A Reporter"\n'
+ 'org = "Example Consulting"\n'
+ 'email = "reporter@example.org"\n'
+ )
+ loaded = config.load(path)
+ self.assertEqual(loaded.reporter["name"], "A Reporter")
+ self.assertEqual(loaded.reporter["org"], "Example Consulting")
+ self.assertEqual(loaded.reporter["email"], "reporter@example.org")
+
+ def test_an_absent_reporter_section_is_an_empty_dict_not_a_crash(self):
+ path = self._write('[general]\ntrusted_relays = ["192.0.2.0/24"]\n')
+ self.assertEqual(config.load(path).reporter, {})
+
+ def test_an_empty_value_is_treated_as_absent(self):
+ # Same rule as cases and as every key init writes: skipped is ABSENT,
+ # never "". An empty org must not reach a report as a stray comma.
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'name = "A Reporter"\n'
+ 'org = ""\n'
+ 'email = "reporter@example.org"\n'
+ )
+ reporter = config.load(path).reporter
+ self.assertNotIn("org", reporter)
+ self.assertEqual(reporter["name"], "A Reporter")
+
+ def test_a_whitespace_only_value_is_treated_as_absent(self):
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'org = " "\n'
+ 'email = "reporter@example.org"\n'
+ )
+ self.assertNotIn("org", config.load(path).reporter)
+
+ def test_a_reporter_value_is_stored_stripped(self):
+ # The name becomes a From display name. Leading and trailing space
+ # survives into the header verbatim, which is sloppy at best and
+ # affects folding at worst.
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'name = " A Reporter "\n'
+ 'email = " reporter@example.org "\n'
+ )
+ reporter = config.load(path).reporter
+ self.assertEqual(reporter["name"], "A Reporter")
+ self.assertEqual(reporter["email"], "reporter@example.org")
+
+ def test_a_reporter_value_that_is_not_a_string_is_rejected_clearly(self):
+ # Not dropped. Dropping reads as not-configured, and this is the one
+ # identity the tool discloses deliberately: a typo that silently
+ # removes the reply address must be reported against the file that
+ # holds it, the same way a non-string trusted_relays entry is.
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ "name = 42\n"
+ )
+ with self.assertRaises(ValueError) as caught:
+ config.load(path)
+ self.assertIn("must be a string", str(caught.exception))
+ self.assertIn("name", str(caught.exception))
+
+ def test_a_non_string_email_is_rejected_rather_than_dropped(self):
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'email = ["reporter@example.org"]\n'
+ )
+ with self.assertRaises(ValueError):
+ config.load(path)
+
+ def test_a_string_reporter_section_is_rejected_clearly(self):
+ # reporter = "A Reporter" is valid TOML and would otherwise raise
+ # AttributeError naming nothing the user can find in their file. It
+ # has to precede [general]: a bare key written after a table header
+ # belongs to that table, not to the document.
+ path = self._write(
+ 'reporter = "me"\n\n[general]\ntrusted_relays = ["192.0.2.0/24"]\n'
+ )
+ with self.assertRaises(ValueError) as caught:
+ config.load(path)
+ self.assertIn("must be a table", str(caught.exception))
+
+ def test_an_unknown_reporter_key_is_ignored(self):
+ # Ignored rather than refused: unlike a manifest format, an unknown
+ # key here loses nothing. Keeping only the three the spec names is
+ # what stops it reaching a report body.
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'email = "reporter@example.org"\n'
+ 'phone = "+1 555 0100"\n'
+ )
+ reporter = config.load(path).reporter
+ self.assertNotIn("phone", reporter)
+ self.assertEqual(reporter["email"], "reporter@example.org")
+
+ def test_a_configured_identity_builds_a_report_body(self):
+ # The round trip that only shows up much later otherwise: the dict
+ # config produces must be the shape report.build() consumes.
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'name = "A Reporter"\n'
+ 'org = "Example Consulting"\n'
+ 'email = "reporter@example.org"\n'
+ )
+ body = report.build(_MANIFEST, _DESTINATION, config.load(path).reporter)
+ self.assertIn("A Reporter <reporter@example.org>", body)
+ self.assertIn(
+ "Reported by: A Reporter, Example Consulting "
+ "<reporter@example.org>", body)
+
+ def test_an_identity_with_only_an_email_still_builds_a_body(self):
+ # Every key is individually skippable per the spec, so config drops
+ # the skipped ones and build() must survive their absence rather
+ # than raising KeyError on a case that parsed fine.
+ path = self._write(
+ "[general]\n"
+ 'trusted_relays = ["192.0.2.0/24"]\n'
+ "\n"
+ "[reporter]\n"
+ 'email = "reporter@example.org"\n'
+ )
+ body = report.build(_MANIFEST, _DESTINATION, config.load(path).reporter)
+ self.assertIn("reporter@example.org", body)
+
def test_the_config_path_follows_xdg_config_home(self):
with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/tmp/xdg-probe"}):
self.assertEqual(
diff --git a/tests/test_report.py b/tests/test_report.py
index e87956f..9159dc7 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -640,6 +640,32 @@ class TextPart(unittest.TestCase):
self.assertLessEqual(len(line), 72, line)
self.assertIn(long_subject, report.unwrap(text))
+ def test_an_identity_missing_a_name_or_org_still_builds(self):
+ """Each [reporter] key is individually skippable.
+
+ config drops a skipped one rather than storing "", so a partial
+ identity is the normal shape here, not a malformed one. Subscripting
+ it raised KeyError on a case that had parsed perfectly.
+ """
+ destination = report.email_destinations(MANIFEST["contacts"])[0]
+
+ text = report.text_part(MANIFEST, destination,
+ {"email": "reporter@example.org"})
+ self.assertIn("Reported by: <reporter@example.org>", text)
+ self.assertNotIn(",", report.unwrap(text).split("Reported by:")[1]
+ .splitlines()[0])
+
+ text = report.text_part(MANIFEST, destination,
+ {"name": "A Reporter",
+ "email": "reporter@example.org"})
+ self.assertIn("Reported by: A Reporter <reporter@example.org>", text)
+
+ text = report.text_part(MANIFEST, destination,
+ {"org": "Example Consulting",
+ "email": "reporter@example.org"})
+ self.assertIn("Reported by: Example Consulting "
+ "<reporter@example.org>", text)
+
def test_a_long_reporter_identity_is_not_truncated(self):
"""The identity is the one thing disclosed deliberately.