aboutsummaryrefslogtreecommitdiffstats
AgeCommit message (Collapse)AuthorFilesLines
14 hoursfeat: read the reporter identity from configDanilo M.4-7/+260
The reporter's identity is the one identifier this tool discloses deliberately, so it comes from config only and parse never supplies it. An empty value is absent, the same rule the rest of the config follows. Three departures from the plan, each a defect in its code: A non-string value is REJECTED, not dropped. The plan filtered on isinstance(value, str), so name = 42 or email = ["a@b"] silently vanished and read back as not-configured. This file already learned that lesson from ipaddress.ip_network(42) returning a valid-looking 0.0.0.42/32: a wrong value that reads as plausible is worse than an error. Dropping the email would strip the reply address from every report while the user believed they were identified, so the typo is reported against the file that holds it, the way a non-string trusted_relays entry already is. The value is stored STRIPPED. The plan tested value.strip() for truthiness but stored the original, so name = " A Reporter " reached the From display name as "From: A Reporter <...>", verbatim and unquoted. Only the three keys the spec names are carried across, and text_part no longer subscripts them. Each key is individually skippable and config drops a skipped one, so a partial identity is the normal shape, yet text_part read identity['name'] and identity['org'] directly: a config naming only an email raised KeyError on a case that had parsed perfectly. The "Reported by:" line is now joined from the parts present, so a missing org leaves no stray comma. A wholly unconfigured identity still raises in build(); how to refuse that belongs to the cli task, not here. The dataclass field defaults to an empty dict rather than editing every construction site, and the mutable-dict-in-a-frozen-dataclass is left as is: report only reads it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
14 hoursfeat: write report bodies into the case directoryDanilo M.2-0/+322
case.py stays the only writer of the manifest, so generate returns it and the caller saves. A modified body is backed up with a timestamp before --force overwrites it. Bodies are written and read as BYTES, not text. The plan used read_text and write_text, which fail two ways at once. The locale encoding is not the utf-8 body_hash pins, so a body hashed differently under a non-UTF-8 LANG; and text mode applies universal newlines, so a report delimited by CRLF came back with every CRLF collapsed to LF. The second was live on every platform: regenerating an untouched case raised Modified, because the body read back never hashed to what was written. For a sent destination that hash is the record of what was disclosed, so it has to be the hash of the bytes on disk. Text mode would also translate the bare LFs inside the machine-readable part on a CRLF platform, corrupting the field block a desk's parser reads. The refusal ordering is now pinned by tests rather than left implicit: a frozen or modified case must leave the bodies directory byte-for-byte as it was, asserted over the whole directory rather than one file. bodies is created without parents, so a missing case directory raises rather than scattering bodies into a tree generate invented. The backup test pins the filename, not just the count of .orig files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
14 hoursfeat: freeze a reported case and detect edited bodiesDanilo M.2-0/+182
Content hash rather than mtime, because mtime is wrong in both directions. Any sent destination freezes the whole case with no override: two desks holding contradictory accounts of one incident is worse than a stale body. The marker is tested for PRESENCE rather than truthiness. It is write-once and monotonic, so an empty or malformed value is a half-written record of a case a desk may already hold, and the safe direction is to refuse; only an explicit null counts as absent. A non-mapping marker refuses too rather than raising AttributeError out of a .get, since cli turns Frozen into a message and a traceback into a bug report. The hash pins utf-8 rather than leaving the encoding to the locale: it is compared against a body read back off disk, possibly under a different LANG, and for a sent destination it is the record of what was disclosed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LByBnw83xr9YP85nskzkyE
29 hoursfeat: assemble the RFC 5965 report documentDanilo M.2-0/+399
Three parts and no message/rfc822: the original carries every identifier the first property keeps out, and text/rfc822-headers is the standard's own answer for a report that cannot include the message. The third part filters the manifest's headers against the same whitelist parse.report_headers() applies, rather than trusting it. The structural argument is that report cannot disclose what it was never given, and a manifest is a file the user edits: that is exactly a way it can be given a To. parse still decides; this refuses to publish what it did not decide for. A header value is attacker-supplied free text the spec keeps deliberately, so a newline in a Subject forges a header line in a part read entirely as headers. RFC 2047 turns the break into a fold instead, and a parser unfolds it back to one header with the value intact. Applied only when a value carries something unsafe, since encoding every header costs the desk the legibility this part exists for. From is built with headerregistry.Address: "Example Consulting, Ltd" through an f-string parses back as two addresses, the first a bogus addr-spec with no domain, so a desk's reply reaches nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursdocs: log the unverified x-arf Report-Type valueDanilo M.1-0/+25
Every other field in the machine-readable part was checked against RFC 5965 itself. This one follows the design document's worked example and no primary x-arf source was reachable to confirm it, so it is recorded rather than left as an assumption in the code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfeat: build the machine-readable feedback report partDanilo M.2-0/+632
An RFC 5965 envelope carrying x-arf fields. 5965 reports are about a message and these are about indicators, so x-arf's Source fills the gap while the report-type keeps a standards parser working. Field values are percent-encoded against header injection. This is reachable, not theoretical: redact.url_valued_parameters() URL-decodes a redirector's destination to recover it as an indicator, so a body carrying "?next=http%3A%2F%2Fa.invalid%2Fx%0AFeedback-Type%3A%20not-abuse" produces, through parse.iocs() on a real message, an IOC whose value holds a literal newline followed by text shaped like a field. Emitted verbatim it forges a field in a report carrying the reporter's identity. Encoding rather than dropping keeps a genuine redirect target; encoding rather than stripping keeps the indicator recoverable, since a stripped URL is a different URL a desk would then act on. The escape covers everything str.splitlines() breaks on, because Python's own email module raises on U+2028 as readily as on LF, and it runs over UTF-8 bytes and escapes "%" so the reversal is a true inverse rather than a second injection one step later. Four corrections to the plan. Source-IP is emitted once, not once per IP: RFC 5965 gives it "once maximum" and a repeat displaces the primary rather than adding an address. Source is omitted when there is no typed indicator rather than emitted empty, since an empty field asserts that the reported thing is the empty string. Arrival-Date is dropped rather than copied from the sender's Date header, which is attacker-controlled and means when the sender claimed to send, not when our MTA received. sha256 and observation are left to the text part rather than forced into the nearest field. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfix: escape the continuation marker so a value cannot forge oneDanilo M.2-9/+308
The marker was an unescaped trailing backslash, and a backslash is legal in a URL path, so a value ending in one was indistinguishable from a wrap. An attacker who read this source could append one and make their own indicator garble itself in the report an abuse desk reads: an adversarial trigger on attacker-supplied text, not an edge case. The short case needed no wrapping at all to corrupt. unwrap() ate the following line regardless, merging an indicator with its own origin annotation. On Subject it was worse, absorbing the Date beneath it and making the "Message as declared" block misstate what the message declared, which is the one thing that block exists to report faithfully. Every backslash is now doubled before wrapping and halved on the way back, and unwrap() tells a marker from content by the PARITY of the trailing run. Doubling only a trailing one would leave "x\\" encoding as "x\" plus a marker, the same bug one character along. A second defect surfaced only under a randomised sweep, after the first fix and a green suite: a break landing BETWEEN the halves of an escaped pair splits the run whose parity unwrap() counts, so a real marker reads as content and the tail is silently dropped. It needs a backslash at exactly the break column, so no hand-written case found it and 454 of 3538 random ones did. The wrap now backs off a character rather than splitting a pair. Verified over 9132 adversarial values, including every backslash pattern up to length 9 and values that are entirely backslashes: 0 round-trip failures, 0 header-block corruptions, no line over 72 columns. Values are escaped, never rejected or sanitised: the report says what the message contained. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfeat: build the human-readable part of a reportDanilo M.2-0/+405
The ask goes first, only this desk's own indicators appear, and the redaction note is unconditional: a desk seeing REDACTED with no explanation may read the report as doctored. Nothing is truncated. The plan cut three lines with a [:72] slice and left the indicator list unwrapped, which is the same defect twice: a cut URL is a WRONG indicator rather than a short one, and a desk acting on the first 72 characters acts on a resource nobody reported. Long values are divided with an explicit trailing-backslash continuation instead, and unwrap() is the exact inverse, so the tests assert reassembly rather than mere presence. That is what rules out a truncation passing as a wrap. Origins are mapped to English. "header-list_unsubscribe" is a parser's vocabulary and reads as debug output; an origin the table does not know is shown as-is, because losing the only line saying where an indicator came from is worse than showing an ugly token. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfeat: list the indicators no abuse desk was found forDanilo M.2-3/+296
Not an error and not an exit code: a case where nothing resolved still reaches MISP and the vendors. Visible beats absent, so review can see it without diffing IOC lists. Two departures from the plan, both about the pair of lists agreeing. An indicator is unreportable only when NONE of its contacts produced a mailable address, not when any one of them failed. Contacts fold by host, so an indicator can sit in a domain contact that resolved and an IP contact that did not; listing it regardless puts it in the destinations and in the "no desk found" list at once, and a reviewer acting on the second hand-reports something already on its way to a desk. An abuse value with no "@", or with either half empty, no longer becomes a destination. RDAP jCard data is third-party and occasionally malformed, and such a value produced a pending destination with an unsendable target, which the unreportable array then skipped because the contact did have an abuse entry. Both functions now apply one mailability test, so the two lists partition the indicators exactly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfix: fold a role mailbox's case, and test the half that was not testedDanilo M.2-12/+127
"Abuse@Host.Invalid" and "abuse@host.invalid" produced two destinations and two mails to one desk, which is the duplicate-mail behaviour the grouping exists to prevent. The root cause is the test, not the grouping. _group_key folded the domain correctly, but the test written for it used a lowercase local part in both of its inputs, so it varied only the half that worked and passed while the other half was never exercised. A test that varies one half of its input proves nothing about the other. The local part now folds for the RFC 2142 role mailboxes. That RFC mandates those names and requires them matched case-insensitively, so no host runs "Abuse@" and "abuse@" as different desks and treating them as two costs a duplicate mail with nothing on the other side of the trade. This reverses the earlier decision only for the standardised names: any other local part is still left as published, because for a named mailbox folding could merge two desks a host genuinely distinguishes, and a dropped desk is worse than a duplicate. Each half now folds on the strength of its own standard. Both folds are mutation-checked and killed by distinct tests, so neither masks the other, and the role fold is checked across all five role names. Also pins two behaviours that held only by luck: the caller's contacts are not modified, and a destination's ioc list is not aliased to the contact's. Both are true today because the grouping starts a fresh list, and nothing would have caught an implementation that reused the contact's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfix: derive a destination id from its address, not its positionDanilo M.2-33/+132
A positional id names a slot in one run's list, and report may run again on a case whose contacts have changed since. A newly resolved indicator whose desk sorts ahead renumbers every desk after it, so bodies/<id>.xarf on disk comes to belong to a different desk than the manifest entry sharing that id. The body hash recorded per destination would then compare one desk's body against another's, reporting an edit nobody made or missing one that was. Hashing the address makes an id follow the desk. It hashes the same normalised form the grouping uses, so two spellings of one desk share an id rather than letting whichever spelling RDAP published first decide a body's filename. Eight hex chars is a deliberate ceiling: collision probability is about 1e-8 at ten desks, and a short id keeps a case directory readable to the person reviewing it. Also strengthens two tests that passed against trivially wrong code: the no-address case now asserts alongside a contact that does resolve, so returning nothing at all is no longer a passing answer, and the two-address case asserts the ioc lists and distinct ids rather than the sorted targets alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfeat: group abuse contacts into one destination per addressDanilo M.2-0/+242
Two contacts can resolve to the same desk, an IP and a domain at one hoster being the common case, and grouping per contact would send that desk two mails about one incident. The domain half of an address is folded to lowercase before grouping, since it is case-insensitive by definition; the local part is left verbatim, because only the receiving host knows whether it folds, and folding two desks a host distinguishes would silently drop one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfix: strip the envelope recipient in every shape the grammar allowsDanilo M.6-5/+160
The for-clause rule anchored on the clause terminator, which is strictly stronger than RFC 5321 4.4: For sits inside Opt-info, so With, ID, Via or a CFWS comment may legitimately follow it, and its ABNF is 1*( Path / Mailbox ) where Mailbox carries no angle brackets. Only the tidiest shape was stripped. Four ordinary ones published the victim's address in the one header a report reproduces verbatim, including "for <a@b> (envelope-from <c@d>);", which is routine Exim and Sendmail output. Anchored on the address instead. The envelope SENDER survives the cut, since that is what the report is about, and the leftover whitespace, orphan separator and emptied comment are tidied because the line is published to a third party verbatim. Table-driven over all five shapes; reverting the regex fails six of nine tests in the class. Two accepted disclosures are now named in the spec's "Where the headers come from" rather than living in a test comment: our own relay's hostname, which the by and authserv-id clauses carry by construction, and attacker-controlled free text in Subject and the From display name, which the whitelist does not and should not filter. The second is asserted as a documented limit so it reads as deliberate, and the manifest assertion now bars the obfuscated you%40example.org spelling alongside the plain one. case.create() seeds an empty headers block so an unparsed case has the same shape as a parsed one. Additive, so FORMAT_VERSION is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
29 hoursfeat: store a whitelist of publishable headers in the manifestDanilo M.6-1/+211
report must never open source.eml, so parse decides once what may be published and report formats only what it is given. To, Cc, Delivered-To and X-Original-To are absent by construction rather than stripped. Received is cut to the boundary hop alone, in both directions. Above it are our own relays; below it is the attacker's own writing, and a forged chain names an innocent third party there, so publishing a hop below the boundary puts someone else's address into a report a desk will act on. That is the third property applied to disclosure rather than to sending_ip(). Truncating the chain was not sufficient on its own: the surviving line is written by our own relay and records the envelope recipient in its optional "for <addr>" clause, so the whitelist alone would have published the victim's address verbatim in the one header a report reproduces in full. The clause is removed and the rest of the hop kept. The manifest-wide "no example.org" assertion is narrowed to the headers block only, where the whitelist deliberately publishes our receiving relay's name in a by/authserv-id clause. The address itself is still barred there, asserted separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
30 hoursdocs: plan the report implementationDanilo M.1-0/+1536
Thirteen tasks, TDD throughout. Task 1 changes parse.py to store a whitelist of publishable headers, which is what lets report never open source.eml, and Task 13 re-runs both sweeps with a third assertion covering generated bodies. Two spec items deliberately have no task and are recorded as gaps: api destination bodies belong to the submit spec, and writing the frozen marker is submit's work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
30 hoursdocs: settle the report specDanilo M.1-0/+412
Settles the three questions the umbrella design left open for report: the X-ARF schema, which fields the reporting identity fills, and the plain-text alternative. The message is not attached. RFC 5965's text/rfc822-headers carries a whitelist of headers instead, and parse stores that whitelist in the manifest so report never opens source.eml, which is the first property applied one level down. Also settles destination grouping per abuse address, an explicit unreportable list, and a freeze rule: any sent destination freezes the whole case, marked by a write-once field, with body integrity checked by content hash rather than mtime. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
30 hoursdocs: document the queried key, mark server as unbuiltDanilo M.1-4/+21
The contacts spec's manifest example carried a "server" field that no code ever wrote, while resolve() writes a "queried" key the spec never defined. They are not the same fact under two names: "queried" is what was asked about, the name the label walk landed on, and "server" is who was asked, the RDAP endpoint the bootstrap selected. Document "queried" as shipped, and record "server" as specified but not built, with the condition that would earn it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xj1ayFRSUQ2u7cwb3S4axE
38 hoursdocs: point at the author's idea note, and log its first itemDanilo M.2-0/+52
The author keeps ideas in an Obsidian note outside this repository. Nothing in the repo pointed at it, so a fresh session would never find it and the backlog would look complete while ideas sat unread. AGENTS.md now names the path and the rule: the note is the inbox, the backlog is the tracked list, and they are reconciled in both directions. Item 3 is that note's one open idea, an API letting qtmaildir tag incoming mail by resemblance to a kept case. It is recorded unsized and explicitly marked as having no verified cause, because unlike items 1 and 2 there is nothing built to verify. The entry records the real tension rather than just the request: this tool has been deliberately mechanical, reporting what a message declared and refusing rather than guessing, and a resemblance score would be its first opinion. It also notes that source.eml is unredacted, so matching must not become a route by which a stored recipient identifier reaches a comparison. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
38 hoursdocs: record the fourth property and the contacts commandDanilo M.4-33/+170
An RDAP query discloses what the user is looking at, and property 1 covers only what is published, so the query rule needed stating in its own right beside the other three. The section records the three leaks it came from, because each one was the same shape: validation applied per branch, forgotten on the next. Also moves rdap.py's imports into one block at the top. Pure move, the suite is 192 either side of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hourstest: make the offline resolve check tamper-evidentDanilo M.1-0/+7
The test asserted only that resolve() returned one entry. It returns one entry whether the injected fetch answered or the real transport was blocked, because resolve() records a transport failure as a per-entry error rather than raising. So the assertion would have stayed green if someone removed the injection, which is the one thing the test exists to notice. It now asserts the handle that only the injected fetch can produce. Removing the injection fails with KeyError: 'handle'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hourstest: prove the suite still opens no socketDanilo M.1-0/+76
The umbrella design verifies property 2 by running with sockets raising. Now that a network module exists, that has to stay true for the WHOLE suite rather than for everything except contacts: a network module that can only be tested with a network is a module that stops being tested. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfix: keep ":" literal so an IPv6 query is not over-encodedDanilo M.2-10/+34
6d5822b quoted the interpolated component with safe="", which encoded the colons of every IPv6 address: 2001:db8::1 went on the wire as 2001%3Adb8%3A%3A1. RFC 9082 specifies the IPv6 object path segment as the address in its text form, and RFC 3986 pchar permits ":" in a segment, so the colon must stay literal. The failure mode was the bad one for this tool: a registry that does not normalise before matching answers 404, which reads here as "this netblock publishes no abuse desk" rather than "we asked the wrong question", losing the abuse contact silently on exactly the IPv6 hops the tool exists to report. safe=":" keeps everything the original commit was for. "/" is still encoded, so the traversal containment is unchanged, and "%" is still encoded, so a scope id stays contained. The regression got through because the happy-path guard used an IPv4 address only. The new test asserts a documentation-range IPv6 address round-trips into the URL unencoded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: add the contacts subcommandDanilo M.2-1/+126
A re-run overwrites contacts[] wholesale rather than merging. A merge would let a contact resolved a week ago survive into a report filed today, which is the stale-address hazard the response caching policy already refuses, and overwriting makes a re-run always safe, which matters because a partial network failure makes re-running the natural next step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfix: percent-encode the component interpolated into an RDAP queryDanilo M.2-2/+112
contacts.is_queryable() is the admission point and remains the real gate, but this property has leaked three times already, each time the same shape: a validator applied to one branch and forgotten on its sibling. First the url branch was guarded and domain leaked, then domain was guarded and the IP branch leaked through an IPv6 scope id. query_ip and query_domain interpolated their component straight into the path, so a caller reaching them without passing through worklist() could put a separator, a query or a fragment on the wire. safe="" rather than urllib's default safe="/" is the point: the default leaves the path separator intact, which is exactly the traversal shape that leaked before. query_domain still returns the UNQUOTED candidate, because that is what the manifest records and the review dialog shows. The tests call both functions directly, bypassing contacts, since a future branch that skips the admission point is the failure this layer exists to survive. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfix: refuse an ipv6 scope id, and say why a value was refusedDanilo M.2-11/+112
Four holes of one class, all in the path that admits a query. ipaddress.ip_address accepts a scope id since Python 3.9, so "fe80::1%victim@example.org" passed _is_ip, is_queryable returned on that branch without inspecting the string further, and rdap.query_ip interpolated the whole thing into the query URL unquoted. A recipient identifier reached a registry, which is exactly the disclosure the fourth property exists to prevent and exactly what the domain-branch fix closed. Reject a scope id at the admission point. fe80::/10 is link-local and never a legitimate RDAP target anyway. The ip branch of worklist() dropped an unparseable value silently rather than funnelling it through add() like the other two branches, so a mangled indicator vanished from the manifest instead of showing up as unusable. It now goes through add(), and is_queryable grew the check that makes that classification correct: an all-digit last label is never a TLD, so "999.999.999.999" is a malformed IP rather than a host to ask a registry about. Two behaviours had no test and both mutations survived. _host_of's root-dot strip is now covered through the url branch, since failing to fold discloses one host to a registry twice; the .lower() half was dead work because urlsplit already lowercases, and is dropped with a comment saying so. The two-label requirement is asserted on is_queryable directly, because a single-label name matches no tld in the bootstrap and a resolve test could never fail whatever the validator decides. The refusal message named no reason, leaving a reviewer unable to tell whether the parser mangled a legitimate host or the attacker planted something. Two cases now: a non-ASCII name is plausibly a real IDN indicator to chase by hand, an illegal character is hostile. Also let KeyboardInterrupt and SystemExit through resolve()'s handler, so stopping a long run is not recorded as a failed query on whichever indicator was in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfix: validate a query host at the one point a query is admittedDanilo M.2-1/+152
THE FOURTH PROPERTY was breached through the domain branch of worklist(). The url branch is cleaned by _host_of, which uses urlsplit().hostname, and the domain branch did only value.strip(".").lower(). rdap.query_domain then interpolates that value into the fetch URL with no quoting. parse._domain_of takes everything after the @ of a From, Sender or Reply-To addr-spec, and email.utils.parseaddr permits /, ?, # and % there, so the whole shape is attacker-controlled through a header they own. A From of `Bank <phish@victim%40example.org.invalid>` sent the recipient's own address to a registry, which is precisely the identity disclosure the property exists to prevent, reaching a third party. `a/../../x.invalid` escaped the /domain/ endpoint altogether, and query, fragment and space values all reached the wire. Fixed at the SINGLE admission point rather than in the offending branch, because per-branch validation is what failed here: one branch was cleaned, the next was written without it. is_queryable() now guards worklist()'s add(), so domain, url and any future branch pass through it. A rejected value is not dropped. It keeps an entry with an empty abuse list and an error saying it was never queried, following the rule suspect_path_segments already sets: flagged and visible to the user during review, because silent was the bug. A non-ASCII host is refused rather than encoded to punycode. Guessing the encoding of an attacker-supplied name is a query that cannot be justified, and the ceiling is noted in a ponytail comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hourstest: make two rdap tests able to fail, and cover the hostile bootstrapDanilo M.1-2/+106
test_the_walk_is_capped could not fail. Its host had nine labels, so the bare-TLD guard alone bounded the walk to eight attempts and the cap was never reached, and the assertion read rdap._MAX_LABEL_WALK at assert time, so the expected value moved with any mutation. Raising the cap to a billion left the suite green. It now uses a twelve-label host and a hard-coded expected count of five, and raising the cap fails it with 11 != 5. test_a_newline_in_an_address_is_rejected asserted a guarantee that a different function happened to provide. email.utils.parseaddr rejects the CRLF fixture by itself, returning an empty address, so deleting both control-character checks from _valid_address left the suite green while the real defence was gone. parseaddr passes \x0b and \x0c straight through, and those reach a mail header raw, so a case for each is added alongside the CRLF one, which is still worth asserting. The new HostileBootstrap cases cover the bootstrap defects fixed in the previous commit: a planted integer CIDR, a malformed services value in seven shapes, a plaintext base URL, and the two paths where a bad entry must not hide a good one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfix: treat the IANA bootstrap as hostile, not as trusted inputDanilo M.1-9/+60
abuse_addresses already treats an RDAP response as attacker-controlled. The bootstrap document comes off the same network and was trusted completely, which is backwards: it decides WHICH server is asked, so subverting it is worth more than subverting an answer. Three ways that hurt the user: A non-string CIDR was passed to ipaddress.ip_network, which does not raise on an integer, it returns a /32. A /32 is the longest possible prefix, so a planted integer won every longest-prefix contest and steered the query for the attacker's own address to a server they control. That server names any abuse address it likes and the user files the phishing report to the phisher. config.py documents this exact trap for the trusted relays; the same mistake was repeated here. A malformed entry raised IndexError, TypeError or KeyError straight out of server_for_ip. bootstrap() writes whatever JSON it receives to the cache with no schema check, so one bad response is persisted and crashes every contacts run for seven days with a traceback pointing at nothing the user can act on. Entry shape is now guarded in the style abuse_addresses uses and a bad entry is skipped, never fatal. _NoDowngradeRedirectHandler guards redirects only, so a bootstrap naming an http:// base sent the initial query in clear text, disclosing which netblock the user is investigating to anyone on the path. The selected URL must now be an https string, which makes the guarantee the module docstring promises hold end to end rather than only on the redirect path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: resolve the worklist to abuse contactsDanilo M.2-0/+186
Failure is per query and never stops the run: a timeout on one indicator must not cost the contacts that did resolve, and a missing contact is a normal outcome rather than an error. Bootstraps are passed in rather than fetched here, so this stays testable with no network and the caller owns the cache policy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: build the contacts worklist, host onlyDanilo M.2-0/+221
Adds the fourth non-negotiable property: a query carries a bare host or IP and never a URL. An RDAP query discloses what the user is looking at, and a URL path can carry recipient identity that parse deliberately flags rather than redacts, because a path segment may be the thing being reported. That decision is safe only while the URL stays local. Property 1 governs what is published and a query appears in no report, so property 1 does not cover this and this property does. Hosts fold, so twenty URLs on one host make one query while the item keeps every indicator id behind it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursdocs: correct the Accept header and the dependency claimDanilo M.2-6/+11
The spec named a strict Accept: application/rdap+json while the plan, and so the code, sends application/rdap+json with application/json at q=0.9. A spec review caught the disagreement. The wider header is the right behaviour, since some registries serve plain application/json and a strict header would lose a contact that was published, so the spec moves to match the code and now records why. Both documents also claimed requirements.txt stays empty. There is no requirements.txt in this repository and there never has been, so the claim was true only vacuously. They now say the tool declares no dependencies, which is the thing actually being promised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursdocs: amend the contacts plan after task 1 to 5Danilo M.1-4/+26
Two corrections found while executing it. The offline proof in task 9 patched socket.socket itself, which breaks the ssl module at import time and produces false failures unrelated to network use. It now patches socket.socket.connect, which is the right target. Task 12 gains a step moving the imports in rdap.py to the top of the file. The module was built one task at a time, so several imports landed mid-file after function definitions. Doing it in the documentation pass keeps it out of the diff that contacts.py builds on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: query RDAP, walking up the labels for a registrable domainDanilo M.2-0/+133
RDAP wants the registrable domain and a deep host is not one. Rather than bundling a Public Suffix List, which is a transcribed table that goes stale weekly and is the failure init.PROVIDERS already documents, this asks the registry, which is the authority on what is registrable. The walk is capped and never queries a bare TLD. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: read abuse addresses from a jCard, strictlyDanilo M.2-0/+172
Only an entity whose roles contain abuse counts. No fallback to a technical or registrant contact, who is a named human that never volunteered for abuse mail, and no fallback to abuse@<domain> by convention: for a phishing domain that mailbox belongs to the attacker, so constructing it would confirm both the catch and that the reporter's address is live. Addresses are validated where they enter rather than where they are used, because the value becomes a mail recipient later and a control character in it is header injection into mail this tool sends. Entity recursion is depth capped so remote JSON cannot hang the tool. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: select an RDAP server by longest prefix and by TLDDanilo M.2-0/+103
Longest prefix rather than first match: a block delegated to a new operator appears as a more specific range inside its parent, and the wider range would name the operator that gave it away. A TLD that publishes no RDAP service selects nothing, which is a normal outcome for many TLDs rather than a defect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: cache the IANA bootstrap, and prefer a stale copy to noneDanilo M.2-0/+132
Seven day TTL under XDG_CACHE_HOME, deliberately not in a case directory: this is a copy of a public map, not evidence. A failed refetch falls back to the stale copy. Staleness is safe in this direction, since a server that has moved simply misses and reads as no contact, while losing IANA entirely would stop the user filing a report. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursfeat: add the RDAP transport, with a redirect cap and no downgradeDanilo M.2-0/+128
The only socket in this tool. A redirect is remote data directing our next request, so hops are capped and an https to http downgrade is refused: a downgraded query travels in clear text and discloses which netblock is under investigation to anyone on the path. The timeout is mandatory rather than defaulted, because urllib with no timeout blocks forever and a hung registry would hang a review. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
39 hoursdocs: plan the contacts implementationDanilo M.2-4/+1989
Twelve tasks, TDD throughout, building bottom up: transport, bootstrap cache, server selection, jCard extraction, querying with the label walk, the worklist, resolution, the subcommand, an offline proof, two sweeps and the documentation. The code in the plan was assembled and run before committing it, against the plan's own tests, 44 of which pass. That found two defects in the plan itself: a parameter that shadowed the cache_dir function and forced a globals() lookup, now renamed to cache_root, and a hand-rolled fake request in the redirect test that lacked the attributes HTTPRedirectHandler reads, now a real urllib Request. Also drops the em dashes from the Documents list in AGENTS.md, which the no-em-dash writing rule covers and which the contacts entry had matched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
40 hoursdocs: spec the contacts subcommandDanilo M.2-0/+349
Settles what the umbrella design left open for this part: RDAP bootstrap, caching policy, and what happens for a netblock that publishes no abuse contact. Scope is IPs and domains, with a URL contributing only its host. The bootstrap files are fetched from IANA and cached with a long TTL, while responses are cached in memory for one run only: a stale bootstrap merely misses, but a stale abuse address sends a report into a dead mailbox silently. The registrable domain is found by walking up the labels and asking the registry rather than by bundling a Public Suffix List, which would be a transcribed table that goes stale weekly. Address extraction is a strict abuse-role match with no fallback. In particular it never constructs abuse@<domain>, because for a phishing domain that mailbox belongs to the attacker and mailing it would confirm both the catch and that the address is live. Adds a fourth non-negotiable property: a query carries a bare host or IP and never a URL. Property 1 governs what is published, and a query is a disclosure that appears in no report, so a path segment that parse deliberately flags rather than redacts would otherwise leak. Transport is urllib with an injected fetch, so requirements.txt stays empty and the suite keeps passing with sockets raising, for the whole suite rather than for everything except contacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wrfqr2xqQfhtXCscU7zrdz
2 daysdocs: record the real-mail sweep and open a backlogDanilo M.2-0/+78
A synthetic fixture only ever holds the shapes someone thought of, and the shapes nobody thought of are the ones that leak. Sweeping the user's own tag:spam is the only source of those, and it earned its place: the first message found a header the parser did not read, and the corpus exercised a spoofed Reply-To display name no fixture had. AGENTS.md records it under Testing, beside the fixture rule it is the deliberate exception to: ask first, work from the scratchpad, compare every address in the raw source against the whole ioc output, and let only counts and stripped domains out. A finding becomes a synthetic fixture; the real message never enters the repository. BACKLOG.md opens with the one thing the sweep found that was not worth fixing blind: boilerplate namespace urls (w3.org doctypes) reported as indicators. Noise rather than a defect, and the argument for leaving it alone is real, so it is written down with both sides rather than built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NHaqA1Rz5ybed7wFUeQbK
2 daysfeat: report List-Unsubscribe urls and a differing SenderDanilo M.4-2/+53
A sweep of the user's real spam found List-Unsubscribe naming a domain that appeared nowhere else in the message. It is attacker infrastructure and was going unreported. Every url from that header goes through redact.url() like a body url: an unsubscribe link has to say who is unsubscribing, which makes it one of the likeliest carriers of a recipient token. mailto: entries are skipped rather than redacted, since the address is the whole value and nothing useful survives removing it. Sender is collected on the same terms as Reply-To, included only when it differs from From. One repeating From is noise; one naming a separate relay is the infrastructure behind the run. Also drops the unused urlencode import left in redact.py when _redact_kv_string stopped using urllib to rebuild the query string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NHaqA1Rz5ybed7wFUeQbK
2 daysfeat: flag an address spoofed into a display nameDanilo M.2-1/+64
A display name naming a recognisable address is a deliberate act and a real signal, so it is reported rather than merely ignored once _domain_of() stopped mistaking it for the sender. The IOC carries no value. One of the identities an attacker impersonates is the recipient themselves, so publishing the impersonated domain would leak the recipient's own domain in exactly the case worth flagging. What travels is only that it happened; the reviewer has the message and can see who was impersonated, and a third party does not need to. display_name_addresses() still returns the full address for local review. Only the published IOC list is stripped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NHaqA1Rz5ybed7wFUeQbK
2 daysfix: take the sender domain from the address, not the display nameDanilo M.3-4/+38
_ADDR_DOMAIN.search() returned the first @domain anywhere in the raw header text. A display name sits before the angle brackets and is attacker-controlled, so it won. Two ways that reached a published report. The sender was misattributed: "Billing at billing@innocent.example" <phish@sender.example.invalid> filed the report against a third party who sent nothing. And it defeated the structural guarantee in sender_domains(): the module reads no recipient header, but an attacker who writes the victim's own address into the display name hands it one anyway, and it came back out as a sender domain. parseaddr() parses the header grammar rather than scanning it, so a quoted display name cannot supply the address. leaky.eml's display name now carries the recipient address. The existing test_no_ioc_holds_a_recipient_address assertion catches this class; it was green before only because the fixture used a harmless domain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019NHaqA1Rz5ybed7wFUeQbK
2 daysfix: prefer the observed address over an attacker's HELO literalDanilo M.2-9/+49
_extract_ip returned the FIRST bracketed IP in a Received header value. Postfix (and others) write the client's own HELO/EHLO argument first and the address it actually observed on the connection second: Received: from [198.51.100.7] (unknown [203.0.113.99]) by mx... The first bracket is entirely attacker-chosen; a client can HELO with any literal it likes. sending_ip() returned 198.51.100.7, reporting whoever the attacker named rather than 203.0.113.99, the address the accepting server itself wrote. This needs no forged extra hop, only a client that HELOs with an address literal, and the module's own docstring already stated the intended answer ("the bracketed literal after the connecting hostname") without the code implementing it. _extract_ip now collects every bracketed, ipaddress-valid literal with its position and, when there is more than one, prefers the last one appearing before " by " (the accepting server's own clause, and the one closest to it). A header with a single bracketed IP or no " by " token keeps the previous single-candidate behaviour, so simple.eml (203.0.113.42) and forged-chain.eml (203.0.113.99, item 3's own mutation-checked test) are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
2 daysfix: re-bracket an IPv6 host when stripping URL userinfoDanilo M.2-2/+24
parts.hostname returns an IPv6 literal WITHOUT its brackets (2001:db8::1, not [2001:db8::1]), and _netloc_without_userinfo reassembled f"{host}:{port}" directly from it: redact.url("http://victim@[2001:db8::1]:8080/p?x=1") -> "http://2001:db8::1:8080/p?x=REDACTED" That string is not parseable back into a host and a port, and the digits after the second-to-last colon are not even part of the address any more. Reporting it means the abuse desk cannot identify the host at all, or misreads it, which is the same class of harm as reporting a wrong IP outright. _netloc_without_userinfo re-adds brackets whenever the hostname contains ":", so an IPv6 host now survives userinfo stripping exactly as an IPv4 or named host already did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
2 daysfix: redact a valueless query token whole, not as a kept nameDanilo M.3-5/+63
parse_qsl reads "?victim@example.org" as the pair ("victim@example.org", ""), and the code kept parameter NAMES because they fingerprint the phishing kit. A name that is itself an address is not a name, so keeping it published the recipient's identifier verbatim (percent-decoded, no less: %40 fools no consumer). _redact_kv_string now splits the query/fragment string by hand on "&" and ";" and inspects each token's own name: one containing "@" is a value that landed in name position and is redacted WHOLE ("?REDACTED" rather than "?victim%40example.org=REDACTED"); an ordinary name still keeps its shape ("?flag" stays "?flag=REDACTED", "?t=1&t=2" stays "?t=REDACTED&t=REDACTED"). This also exposed a second leak reachable through the same fixture: parse._suspect_segments() ran redact.suspect_path_segments()'s decode-and-check predicate (meant for a querystring smuggled past percent-encoding into a PATH segment) against a raw query VALUE, so a plaintext "?e=you@example.org" reproduced the address in the manifest's suspect_path_segments flag even though the URL itself was correctly redacted. redact.suspect_path_segments() now exposes the opaque-shape half of its check as _looks_opaque(), and parse.py uses only that half against query values: a query value is always fully redacted regardless, so the flag may hint at its shape but must never reproduce it. Turns tests.test_parse.TestIocAssembly.test_the_address_does_not_survive_any_url_shape green, and closes the gap test_no_ioc_holds_a_recipient_address had been passing over with URL redaction fully disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
2 daystest: wire leaky.eml into the anti-leak suite (red, defect 2)Danilo M.2-1/+29
test_no_ioc_holds_a_recipient_address grepped for the literal "example.org", and every existing fixture hides the recipient address as base64, so URL redaction could be disabled entirely and both this test and test_cli's counterpart stayed green. leaky.eml carries you@example.org in five URL shapes plus a From display-name trap. Adding it to the fixture list, plus a new test asserting on the raw address and its percent-encoded form, turns the suite red: the valueless-query-parameter defect (redact.py) currently lets ?victim@example.org through as a kept parameter NAME. Left failing on purpose; the next commit fixes redact.py and turns it green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
2 daysdocs: AGENTS.md, with CLAUDE.md pointing at itDanilo M.2-0/+259
Same arrangement as qtmaildir: one source of truth, and a thin pointer beside it so every agent tool reads the same file. It leads with the three properties that are not negotiable, because each has a concrete victim and each is a thing a later change could quietly break. Recipient identifiers must never reach a report, and the guarantee is structural rather than a step someone remembers. Nothing is fetched or resolved, which the suite verifies by running with sockets disabled. The trust boundary is configured rather than guessed, and its test is mutation-checked because walking one hop too far reports an innocent party the attacker named. It also records the traps that were found the hard way rather than reasoned about: ipaddress.ip_network(42) returning 0.0.0.42/32 instead of raising, a string trusted_relays iterating characters, goog.json looking like the right SPF source while listing all of Google, and the provider table having been wrong in every entry when it was first written from memory. The hand-testing rule is stated with its evidence: the prompts are the user's to test, and one pass over them found five defects, four being the same mistake of validating an answer somewhere other than where it was given. Every factual claim in it was checked against the code before committing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
2 daysdocs: README covers init and parseDanilo M.1-10/+83
Marks what is built rather than describing the whole pipeline as though it existed, documents the three ways to answer the trust-boundary question, and shows what a case directory holds and what an origin and confidence mean on an indicator. Every command in it was run before committing, including the two non-interactive forms. The tests section names the two checks that are not ordinary unit tests, because they are the ones a reader would otherwise not know to keep: the Received-chain mutation check, and the parser suite running with sockets disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
2 daysfix: validate each setup answer at the prompt that asked for itDanilo M.1-34/+101
Five defects from the user's hand test of the interactive setup, four of them the same mistake: an answer was validated somewhere other than where it was given, so a typo cost the whole run. A bad CIDR was only rejected after the NEXT question had been asked and answered, and both answers were then discarded. It is validated at its own prompt now and re-asks, so a mistake costs one retry. A provider name typed at the first prompt was treated as a CIDR, so "Gmail" produced "does not appear to be an IPv4 or IPv6 network", which names nothing the user can act on. The prompt takes either answer now and tries the provider table first, because the user does not know, and should not have to know, which of the two questions they are answering. Picking a hop number out of range raised a bare IndexError, and a non-numeric pick leaked int()'s own message about base 10. Both re-ask naming the valid range. The first question said what to type without saying what it was for. It now states that the boundary decides which address gets reported, which is the fact that makes a wrong answer matter. Ctrl+C and EOF are caught in main() and report that nothing was written. Setup writes nothing until every answer is in hand, so abandoning it midway leaves no half-written config, and that was already true; it just said so with a traceback. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R