aboutsummaryrefslogtreecommitdiffstats
path: root/AGENTS.md
diff options
context:
space:
mode:
Diffstat (limited to 'AGENTS.md')
-rw-r--r--AGENTS.md94
1 files changed, 85 insertions, 9 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 8f37be8..a8741f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,7 @@ python3 -m unittest tests.test_parse -v
Run the tool from the checkout with `python3 -m abusectl`.
-## THREE PROPERTIES THAT ARE NOT NEGOTIABLE
+## FOUR PROPERTIES THAT ARE NOT NEGOTIABLE
Each has a concrete victim. Do not weaken one for convenience, and do not
"simplify" the code that enforces it without reading this section first.
@@ -112,6 +112,47 @@ With no boundary configured, `parse` REFUSES rather than guessing the outermost
public IP. Refusing is only defensible because `abusectl init` is the route out,
which is why that command exists at all.
+### 4. A query carries a bare host or IP, never a URL
+
+`contacts` is the first part that talks to anyone. An RDAP query discloses to a
+third party which host or netblock the user is investigating, so it carries a
+BARE HOST OR IP ADDRESS and nothing else.
+
+**Property 1 does not cover this.** Property 1 governs what is PUBLISHED, and a
+query appears in no report at all. A URL path can carry recipient identity, and
+`suspect_path_segments` deliberately FLAGS a suspect segment rather than
+redacting it, which is safe only while the URL stays local. The moment a URL
+becomes a query it stops being local, and the flag protects nobody. The victim
+is the recipient whose address sits in a path segment and is handed to a
+registrar, and, when the registrar is the attacker's own, the reporter, who has
+just told the attacker they are under investigation.
+
+`contacts.is_queryable()` is the SINGLE ADMISSION POINT. Every branch of
+`worklist()` funnels through `add()`, which refuses a value that is not a bare
+host or IP and keeps it as `unusable` so the user sees it during review. Do not
+move that check into a branch, and do not add a branch that reaches `rdap`
+without passing through it.
+
+**That rule exists because the property leaked three times, each time the same
+shape.** First the `url` branch was guarded with a host-only extraction and the
+`domain` branch was not, so `From: Bank <phish@victim%40example.org.invalid>`
+sent the recipient's own address to a registry. Then the `domain` branch was
+guarded at one admission point and the IP branch still leaked, because
+`_is_ip()` sat INSIDE that point and returned True before the validator ran, and
+`ipaddress.ip_address()` accepts an IPv6 scope id whose content is unconstrained
+free text: `fe80::1%victim@example.org` parses as valid. The lesson is both
+halves of that: **validation applied per branch gets forgotten on the next
+branch, and a check that short-circuits before the validator is the same bug
+wearing a different coat.**
+
+`rdap._quoted()` percent-encodes a component so it cannot escape its path
+segment. That contains URL-STRUCTURE attacks, traversal and an injected query or
+fragment; it does NOT reduce disclosure, because an encoded address is still the
+address. It is not a second line of defence for this property, and the third
+leak came from treating it as one: `safe=""` encoded the colons RFC 9082
+requires be literal, so every IPv6 query silently 404ed and read as "no abuse
+desk published".
+
## Architecture
```
@@ -122,10 +163,12 @@ abusectl/
parse.py .eml -> IOCs pure, offline
redact.py the safety rule, alone and testable
case.py case directory: create, manifest read/write, atomic
+ contacts.py IOCs -> abuse contacts network, read-only
+ rdap.py bootstrap, query, jCard network, read-only
```
-Planned, each needing its own spec first: `contacts` (RDAP), `report` (X-ARF),
-`submit` (MISP then vendors), `retry` (cron). See the design document.
+Planned, each needing its own spec first: `report` (X-ARF), `submit` (MISP then
+vendors), `retry` (cron). See the design document.
**`redact.py` is separate from `parse.py` deliberately.** It is the safety
property, and a module of its own gets tests that name it rather than tests
@@ -265,6 +308,36 @@ When a sweep finds a defect, reproduce it as a synthetic fixture under the
rule above and commit THAT. The real message stays in the scratchpad, which
is per-session and outside the repository.
+**A module that talks to third parties gets TWO sweeps, and they are not the
+same shape.** `contacts` was verified this way and any later network part
+should be too.
+
+Sweep A is OFFLINE and covers the whole corpus. It builds the worklist for
+every message and asserts the fourth property against it, with no query issued.
+Breadth is free because nothing leaves the machine. What it observed: 92
+messages, 1685 indicators, 0 crashes, 0 empty parses, 110 unique query targets,
+1206 indicators folded away by host deduplication, and 0 malformed queries. The
+second assertion took all 548 addresses appearing in the raw sources and checked
+each against every query string: no address reached a query, and neither did any
+local part of six characters or more.
+
+Sweep B is ONLINE, hand-picked and deliberately SMALL, because every query in it
+discloses to a third party and cannot be undone. Twelve targets, chosen to
+exercise the branches rather than to cover volume: 12 of 12 completed, 0
+failures. All five RIRs, RIPE, ARIN, APNIC, LACNIC and AFRINIC, returned a
+parseable jCard. IPv6 resolved live. The label walk reduced `www.ripe.net` and
+`a.b.c.example.org` to two labels, and `nic.uk` resolved, which is the
+multi-part suffix case. Three non-resolutions were correct behaviour rather than
+failures: AFRINIC and `nic.cz` publish a handle but no `abuse` role, and
+`example.museum` has no RDAP server for the TLD. The IANA bootstrap held 5 IPv4
+services, 5 IPv6 and 590 DNS.
+
+**Sweep B must never draw its targets from the user's own spam corpus.** A query
+tells a registrar which of their customers someone is investigating, and for a
+phishing domain that registrar may be the attacker's own. Pick targets that are
+public infrastructure or documentation names, and keep the corpus to sweep A,
+where nothing is sent.
+
## Working on this repo
Work directly on `master`, no PR flow. Commits are GPG-signed (`git commit -S`);
@@ -273,9 +346,9 @@ rejection is correct until proven otherwise.
`HANDOFF.md` is local-only and gitignored; never stage or commit it.
-Design first for anything unbuilt: `contacts`, `report`, `submit` and `retry`
-each need their own spec before code, because each has real unknowns. The
-umbrella design settles only what they share.
+Design first for anything unbuilt: `report`, `submit` and `retry` each need
+their own spec before code, because each has real unknowns. The umbrella design
+settles only what they share.
## Documents
@@ -283,11 +356,14 @@ umbrella design settles only what they share.
before changing anything about the case format, the redaction rule, or the
ordering between MISP and the vendors.
- `docs/specs/2026-09-09-contacts.md`, the `contacts` spec. Read it before
- touching RDAP, the bootstrap cache, or anything that issues a query: it adds
- a FOURTH non-negotiable property, that a query carries a bare host or IP and
- never a URL.
+ touching RDAP, the bootstrap cache, or anything that issues a query. The
+ fourth property it introduced is stated above in its own right; the spec
+ carries the reasoning behind the rest of the module.
- `docs/plans/2026-09-08-parse.md`, the plan `init` and `parse` were built
from. Historical once built, but it records why each test exists.
+- `docs/plans/2026-09-09-contacts.md`, the plan `contacts` and `rdap` were
+ built from. Historical in the same way, and it records the three leaks the
+ fourth property above is written from.
- `docs/BACKLOG.md`, open items, with the cause verified in the code rather
than assumed. Read it before starting work; add to it rather than fixing
something unasked.