From e65e598f4fbdcdfdd0b837abe3c794862f66a3e6 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 8 Sep 2026 13:20:51 +0200 Subject: plan: implementation plan for init and parse Thirteen tasks, TDD throughout, stdlib only. parse is pure and offline: the trusted-relay boundary arrives as an argument rather than a config read, so the whole extractor is testable against fixtures with no setup. The plan carries three checks that are not ordinary unit tests. The Received-chain task has a mutation step, because walking one hop too far reports an innocent third party named in a header the attacker wrote, and a test that cannot fail would not protect against it. The URL task runs the suite with sockets refused, so the never-fetch rule is verified rather than read. And every fixture is asserted to leave no recipient address anywhere in the manifest. init exists because parse refuses to guess the trust boundary. It asks for CIDRs, offers a static table of known provider ranges, or reads the chain of a known-good sample and lets the user pick their own hops. A pure builder with the prompts and the flags as two front ends over it, so --non-interactive covers agent-driven setup and the config writing is tested without a terminal. Re-running shows what is already configured and asks; either route backs the old file up first and preserves sections this run does not set, so a later init cannot silently drop a MISP key. The prompts themselves are hand-tested rather than driven from stdin: a test there would assert the wording it was written against and break on a rewording that improved it. Task 12 is the checklist, weighted towards wrong answers. Redirect chains were missing from the first draft of the plan and are now specified: a parameter whose value is itself a URL is recovered as an indicator while every other value stays redacted, which resolves the conflict between reporting the destination and never publishing a tracking token. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R --- docs/specs/2026-09-08-abusectl-design.md | 84 +++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) (limited to 'docs/specs/2026-09-08-abusectl-design.md') diff --git a/docs/specs/2026-09-08-abusectl-design.md b/docs/specs/2026-09-08-abusectl-design.md index ed984dd..6b2e559 100644 --- a/docs/specs/2026-09-08-abusectl-design.md +++ b/docs/specs/2026-09-08-abusectl-design.md @@ -56,6 +56,7 @@ One tool, subcommands, one repository. Each subcommand reads and writes a therefore take a week and survive a reboot. ``` +abusectl init -> config.toml first run, then exits abusectl parse msg.eml -> case dir, IOCs offline, pure abusectl contacts -> + abuse contacts network, read-only abusectl report -> + report bodies offline, pure @@ -175,11 +176,24 @@ The full URL survives in `source.eml` either way, so the evidence exists locally; it is simply not what gets published by default, and the review gate allows pasting one in by hand when a particular desk genuinely needs it. +**One exception, and it is the rule's own logic rather than a hole in it.** A +redirector carries its DESTINATION in a parameter, which the rule above would +blank. That destination is an indicator rather than a recipient identifier, so +a parameter value that parses as an http(s) URL is recovered and reported in +its own right, redacted itself, and the hop is recorded as a chain. Every +value that is not a URL stays blanked, including a tracking token sitting in +the same query string. The recovery is bounded in depth, because a redirector +may point at another one and the nested value is attacker-supplied. + +Reading a destination out of a parameter is not fetching it. The chain is what +the message DECLARES; nothing is followed. + ## Components ``` abusectl/ cli.py argparse dispatch, exit codes. No logic. + init.py first-run config: pure builder + prompt shell case.py case dir: create, load, save manifest, atomic writes parse.py .eml -> IOCs stdlib only, pure contacts.py IOCs -> abuse contacts (RDAP) network, read-only @@ -210,6 +224,68 @@ an edit to `submit.py`. This is the one place a plugin shape earns itself, because there are four known members with genuinely different APIs. There is no discovery mechanism; it is a package with four members. +## First run: `abusectl init` + +**`parse` REFUSES to run with no `trusted_relays` configured**, rather than +guessing. The outermost public IP is the usual guess and it is wrong in +exactly the case that matters: an attacker who forges extra `Received` +headers. A confident wrong answer here gets an innocent third party reported. + +Refusing is only defensible with a route out, so `abusectl init` writes the +config and exits, and `parse`'s error names it rather than stating a bare +failure. + +**It asks only what the part being built needs.** Today that is the trusted +relays and the cases directory. The MISP URL and key, the vendor keys and the +X-ARF identity arrive as questions when the parts that use them are built, so +no question is written before its part is designed. + +**A skipped answer is ABSENT from the file, never an empty string.** +`api_key = ""` reads as configured-and-broken and produces a confusing auth +error much later; an absent key reads as not-configured, and the part that +wants it can say so plainly. + +### The trusted-relay question has three tiers + +It is the answer a user is most likely to get wrong, and it is the one that +decides whether the reported IP is the attacker's or an innocent relay's. + +1. **Ask for CIDRs.** Validated with stdlib `ipaddress`, so a malformed entry + is rejected at the prompt rather than at parse time. +2. **A known-provider table.** Gmail, Fastmail, Proton and similar publish + their sending ranges. The table ships with the tool as static data. + Deliberately NOT read from SPF at runtime: that is a DNS lookup, and while + the never-resolve rule is about parsing hostile mail rather than about + setup, a static table keeps the boundary unambiguous. +3. **`--from-sample `.** For self-hosted mail and anything absent + from the table: show the `Received` chain of a message the user knows + arrived legitimately, and let them pick which hops are theirs. This turns + an abstract question into choosing from a real list. + +### Two front ends, one writer + +`init` is a **pure builder plus a thin prompt shell**. One function takes the +answers as a mapping and returns the TOML text; the interactive prompts and +the command-line flags are two front ends over it. So the config-writing logic +is testable with no terminal and no files, and the two routes cannot drift. + +**`--non-interactive` exists so an agent can run setup**, taking every answer +as a flag and failing on a missing one rather than prompting. Every question +is also a flag; there is no answer reachable only by typing. + +**`init` never overwrites silently.** Re-running over an existing config +refuses unless `--force`, and `--force` still never drops a key the new run +did not supply: a config holding a MISP key must not lose it to a later run +that only set the relays. + +### What is tested and what is hand-tested + +The builder has right answers and is tested: the TOML it produces, a skipped +key being absent, a malformed CIDR being rejected, an existing config not +being clobbered. **The prompts are hand-tested by the user**, because whether +a question reads clearly has no assertion; a test driving stdin would assert +the wording it was written against and break on a rewording that improved it. + **`config.py`** reads TOML through stdlib `tomllib`, no dependency. It holds the cases path, the MISP URL and key, vendor keys, the user's reporting identity for X-ARF, `max_attempts` for the retry cap, and the trusted-relay @@ -228,6 +304,7 @@ already runs `mailsync.sh` from, and not to a process of this tool's own. | Part | Needs | |---|---| +| `init` | stdlib only | | `parse` | stdlib only | | `contacts` | an HTTP client | | `report` | stdlib only | @@ -491,8 +568,11 @@ keys. infrastructure.** Headers can be forged wholesale, and only the hops the user's own MTA added are trustworthy. Without a configured trusted-relay boundary, the "sending IP" is whatever the attacker chose to write. The -boundary is config; IOCs below it are marked `untrusted-hop` rather than -presented as fact. +boundary is config, supplied to `parse` as an ARGUMENT so the module stays +pure and config-free; `cli.py` reads it and passes it in. IOCs below the +boundary are marked `untrusted-hop` rather than presented as fact, and with no +boundary configured at all `parse` refuses to run rather than guessing, per +the `init` section above. **Never resolve and never fetch.** Not the URLs, not the redirects, not remote images. Following a link confirms the address is live to the sender and fires -- cgit v1.2.3