diff options
| -rw-r--r-- | abusectl/cli.py | 55 | ||||
| -rw-r--r-- | abusectl/init.py | 67 | ||||
| -rw-r--r-- | docs/BACKLOG.md | 28 | ||||
| -rw-r--r-- | tests/test_init.py | 138 |
4 files changed, 282 insertions, 6 deletions
diff --git a/abusectl/cli.py b/abusectl/cli.py index ed31a4b..36a12ec 100644 --- a/abusectl/cli.py +++ b/abusectl/cli.py @@ -192,6 +192,59 @@ def _ask_relays() -> list[str]: return candidates +def _ask_reporter_email() -> str: + """Ask for the reply address, re-asking on an answer that cannot be one. + + Validated HERE rather than after the next question, the shape of defect + the first hand test found four of. Skippable: an empty answer returns + empty and the key is simply absent from the file. + + ponytail: the ceiling is deliberately low. This is not an RFC 5322 + validator and must not become one, because the failures those catch are + not the failures that happen: what a typo actually costs is a report + whose reply address bounces, and the answers that produce that are the + ones with no @ at all (a name pasted in), a spelled-out "at", or a + stray space from a copy-paste. Anything past that would start rejecting + addresses that work. + """ + while True: + answer = _ask( + "\nYour email for abuse desks to reply to (Enter to skip): " + ) + if not answer: + return "" + if answer.count("@") == 1 and all(part.strip() for part in answer.split("@")): + if not any(c.isspace() for c in answer): + return answer + print(" That does not look like an address abuse desks could reply to.") + print(" Expected something like you@example.org, or Enter to skip.") + + +def _ask_reporter() -> dict: + """Ask for the identity that goes IN the reports, all three skippable.""" + print("\nabusectl puts your identity in the reports it sends, so an abuse") + print("desk can tell who reported and reply to you. This is the one thing") + print("the tool discloses on purpose; everything from the message itself") + print("is redacted. Leave any of these blank to skip it.") + + answers = { + "reporter_name": _ask("\nYour name (Enter to skip): "), + "reporter_org": _ask("Your organisation (Enter to skip): "), + "reporter_email": _ask_reporter_email(), + } + + # Said here rather than discovered later. The address is the one field a + # report cannot be built without, since it becomes the From, and skipping + # it is a legitimate choice at setup: the file is hand-editable and this + # run may only be about the relays. What is not acceptable is finding out + # at `report` time with nothing explaining why. + if not answers["reporter_email"]: + print("\n No reply address set. `abusectl report` needs one, so add") + print(" email under [reporter] before your first report, or re-run init.") + + return answers + + def _prompt_answers(sample: Path | None) -> dict: """Ask the interactive questions and return an answers dict for init.build. @@ -204,7 +257,7 @@ def _prompt_answers(sample: Path | None) -> dict: trusted_relays = _ask_relays() cases = _ask(f"\nCases directory [{config.DEFAULT_CASES}]: ") - return {"trusted_relays": trusted_relays, "cases": cases} + return {"trusted_relays": trusted_relays, "cases": cases, **_ask_reporter()} def _cmd_init(args) -> int: diff --git a/abusectl/init.py b/abusectl/init.py index d10f6ed..626dd41 100644 --- a/abusectl/init.py +++ b/abusectl/init.py @@ -158,8 +158,16 @@ def _quoted(value: str, field: str) -> str: return f'"{value}"' +# The answer key each [reporter] field is asked under. +_REPORTER_FIELDS = ( + ("name", "reporter_name"), + ("org", "reporter_org"), + ("email", "reporter_email"), +) + + def build(answers: dict) -> str: - """Render answers as config TOML for the [general] table.""" + """Render answers as config TOML for the [general] and [reporter] tables.""" relays = _validate_relays(answers.get("trusted_relays", [])) lines = [ @@ -180,6 +188,30 @@ def build(answers: dict) -> str: if isinstance(cases, str) and cases.strip(): lines.append(f"cases = {_quoted(cases, 'cases')}") + # Written STRIPPED, matching config.load(), which strips on read. A file + # whose text differs from what every consumer sees is a seam worth not + # having, and the name becomes a From display name where surrounding + # whitespace would survive verbatim into a header. + reporter = [] + for key, answer_key in _REPORTER_FIELDS: + value = answers.get(answer_key, "") + if isinstance(value, str) and value.strip(): + reporter.append(f"{key} = {_quoted(value.strip(), key)}") + + # Emitted only when something was answered. An empty [reporter] reads as + # configured-with-nothing, the same trap as api_key = "": absent reads as + # not-configured and the part that wants it can say so plainly. + if reporter: + lines.extend([ + "", + "# The one identity abusectl discloses DELIBERATELY: it goes in the", + "# reports you send, so an abuse desk can reply to you. Everything", + "# else the tool touches is redacted; this is not.", + "", + "[reporter]", + ]) + lines.extend(reporter) + lines.extend([ "", "# Later parts of abusectl add further sections here:", @@ -238,12 +270,34 @@ def back_up(path: pathlib.Path) -> pathlib.Path | None: return backup_path -def _preserved_sections(path: pathlib.Path) -> str: - """Return the raw text of every top-level table except [general]. +def _rendered_sections(text: str) -> set: + """Return the names of the top-level tables actually present in text. + + Read back off the rendered TOML rather than assumed from the answers: + build() emits [reporter] only when something was answered, and the + question preservation must answer is what this run WROTE, not what it + could have written. + """ + names = set() + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("[") and stripped.endswith("]"): + names.add(stripped.lstrip("[").rstrip("]").strip()) + return names + + +def _preserved_sections(path: pathlib.Path, built: set) -> str: + """Return the raw text of every top-level table build() did not render. Carrying raw lines rather than re-rendering means a comment, or a field this build does not understand, also survives an init that only sets the relays. + + A table build() DID render is dropped instead: emitting it twice makes + the file unreadable, since tomllib refuses a duplicate table and the + whole config, [misp] key included, goes with it. `built` names those, + and it comes from the rendered text rather than a hand-kept list, so a + section added to the builder cannot be forgotten here. """ if not path.exists(): return "" @@ -256,7 +310,7 @@ def _preserved_sections(path: pathlib.Path) -> str: for line in lines: stripped = line.strip() if stripped.startswith("[") and stripped.endswith("]"): - skipping = stripped.lstrip("[").rstrip("]").strip() == "general" + skipping = stripped.lstrip("[").rstrip("]").strip() in built if skipping: continue if not skipping: @@ -278,8 +332,11 @@ def write(path: pathlib.Path, answers: dict, force: bool = False) -> pathlib.Pat if path.exists() and not force: raise FileExistsError(f"{path} already exists; pass --force to overwrite") - preserved = _preserved_sections(path) text = build(answers) + # Only what this run actually rendered is dropped from the old file, so + # an init that skips every reporter question keeps the identity the user + # set by hand instead of silently deleting it. + preserved = _preserved_sections(path, _rendered_sections(text)) if preserved: text = text.rstrip("\n") + "\n\n" + preserved + "\n" diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 780cbf5..63ea52c 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -134,3 +134,31 @@ to name the field differently, the fix is one string and one test. a field an x-arf parser skips rather than acts on wrongly. Worth doing before the first real report is filed, so a desk running x-arf tooling gets what it expects. + +## 5. `report.build()` raises KeyError on an identity with no email + +**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 +saying what is missing. Reproduced through the public API against the real +config reader: `config.load()` on a file whose `[reporter]` sets `name` and +omits `email` returns `{"name": "A Reporter"}`, and that dict raises. + +**Cause.** Every key in `[reporter]` is independently optional, by the same +skipped-answer-is-absent rule the rest of the config follows, but the report +builder treats one of them as required without checking. Task 9 fixed the +config half of this seam; the report half was not reachable from a config file +until `init` grew the three prompts, and now it is: skipping the email +question while answering the name produces exactly this shape. + +**Approach.** Refuse before building, not after: raise a named error saying no +reply address is configured. Filling in an empty addr-spec instead would be +worse, since a `From:` with no address produces a report that is sent and +cannot be replied to, which defeats the reason the identity is disclosed at +all. `init` now warns at the prompt when the address is skipped, so the +remaining gap is a hand-edited config and the error message it deserves. + +**Constraints.** Belongs with the `report` subcommand rather than the builder +alone, since where the check lives decides whether the user sees an exit code +and a sentence or a traceback. Not a leak: the failure is loud and nothing is +sent. diff --git a/tests/test_init.py b/tests/test_init.py index 619d6ba..46b1a26 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -59,6 +59,144 @@ class TestBuildConfig(unittest.TestCase): "cases": 'x" \ntrusted_relays = ["0.0.0.0/0"]\n#'}) +class TestReporterSection(unittest.TestCase): + def test_the_identity_becomes_a_reporter_table(self): + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "A Reporter", + "reporter_org": "Example Ltd", + "reporter_email": "abuse@example.org", + }) + parsed = tomllib.loads(text) + self.assertEqual(parsed["reporter"], { + "name": "A Reporter", + "org": "Example Ltd", + "email": "abuse@example.org", + }) + + def test_a_fully_skipped_identity_emits_no_table_at_all(self): + # Not an empty [reporter]: an empty table reads as configured, and + # the reader would then report an identity of nothing rather than + # saying plainly that none is set. + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "", + "reporter_org": "", + "reporter_email": "", + }) + self.assertNotIn("[reporter]", text) + self.assertNotIn("reporter", tomllib.loads(text)) + + def test_a_skipped_answer_is_absent_not_empty(self): + # Same rule as the cases path: "" reads as configured-and-broken. + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "A Reporter", + "reporter_org": "", + "reporter_email": " ", + }) + reporter = tomllib.loads(text)["reporter"] + self.assertEqual(reporter, {"name": "A Reporter"}) + + def test_values_are_written_stripped(self): + # config.load() strips on read, so writing unstripped would make the + # file disagree with what every consumer sees. + text = init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": " A Reporter ", + }) + self.assertEqual(tomllib.loads(text)["reporter"]["name"], "A Reporter") + + def test_a_quote_in_an_identity_cannot_break_out_of_the_toml(self): + with self.assertRaises(ValueError): + init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": 'x"\nemail = "attacker@example.invalid"', + }) + + def test_the_identity_loads_back_through_config(self): + # The seam report.build() consumes: what init writes must arrive as + # the dict shape the reader hands over, keys and all. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text(init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "A Reporter", + "reporter_org": "Example Ltd", + "reporter_email": "abuse@example.org", + }), encoding="utf-8") + self.assertEqual(config.load(path).reporter, { + "name": "A Reporter", + "org": "Example Ltd", + "email": "abuse@example.org", + }) + + def test_a_partial_identity_loads_back_with_only_what_was_given(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text(init.build({ + "trusted_relays": ["192.0.2.0/24"], + "reporter_email": "abuse@example.org", + }), encoding="utf-8") + self.assertEqual(config.load(path).reporter, + {"email": "abuse@example.org"}) + + +class TestReporterCarryAcross(unittest.TestCase): + def test_an_identity_set_by_hand_survives_a_relays_only_rewrite(self): + # AGENTS.md: sections build() does not produce are carried across + # verbatim. Skipping all three answers must not delete an identity + # the user set earlier; there would be no warning that it went. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text( + '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' + '[reporter]\nname = "A Reporter"\nemail = "abuse@example.org"\n', + encoding="utf-8", + ) + init.write(path, {"trusted_relays": ["192.0.2.0/24"]}, force=True) + self.assertEqual(config.load(path).reporter, + {"name": "A Reporter", "email": "abuse@example.org"}) + + def test_a_new_identity_replaces_the_old_one_without_duplicating_it(self): + # A section the builder DOES produce must not also be carried across: + # two [reporter] tables in one file is not merely untidy, tomllib + # refuses the whole file and the config becomes unreadable. + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text( + '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' + '[reporter]\nname = "Old Name"\n', + encoding="utf-8", + ) + init.write(path, { + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "New Name", + }, force=True) + + self.assertEqual(path.read_text().count("[reporter]"), 1) + self.assertEqual(config.load(path).reporter, {"name": "New Name"}) + + def test_an_unrelated_section_still_survives_alongside_an_identity(self): + with tempfile.TemporaryDirectory() as tmp: + path = pathlib.Path(tmp) / "config.toml" + path.write_text( + '[general]\ntrusted_relays = ["10.0.0.0/8"]\n\n' + '[reporter]\nname = "Old Name"\n\n' + '[misp]\napi_key = "kept"\n', + encoding="utf-8", + ) + init.write(path, { + "trusted_relays": ["192.0.2.0/24"], + "reporter_name": "New Name", + }, force=True) + + rewritten = path.read_text() + self.assertIn("kept", rewritten) + self.assertNotIn("Old Name", rewritten) + self.assertEqual(config.load(path).reporter, {"name": "New Name"}) + + class TestProviderTable(unittest.TestCase): def test_a_known_provider_resolves_to_ranges(self): self.assertTrue(init.provider_relays("gmail")) |
