aboutsummaryrefslogtreecommitdiffstats
path: root/docs/specs/2026-09-08-abusectl-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs/2026-09-08-abusectl-design.md')
-rw-r--r--docs/specs/2026-09-08-abusectl-design.md84
1 files changed, 82 insertions, 2 deletions
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 <case> -> + abuse contacts network, read-only
abusectl report <case> -> + 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 <msg.eml>`.** 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