diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-08 13:20:51 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-08 13:20:51 +0200 |
| commit | e65e598f4fbdcdfdd0b837abe3c794862f66a3e6 (patch) | |
| tree | 837966360441b70927206514d09550262211d0ce /docs/specs/2026-09-08-abusectl-design.md | |
| parent | 9ac5e7485e5f53f089c8932a8b13bc725b1ba029 (diff) | |
| download | abusectl-e65e598f4fbdcdfdd0b837abe3c794862f66a3e6.tar.gz abusectl-e65e598f4fbdcdfdd0b837abe3c794862f66a3e6.zip | |
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
Diffstat (limited to 'docs/specs/2026-09-08-abusectl-design.md')
| -rw-r--r-- | docs/specs/2026-09-08-abusectl-design.md | 84 |
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 |
