| Age | Commit message (Collapse) | Author | Files | Lines |
|
_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
|
|
_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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
An empty cases string resolved to Path("") = cwd, scattering evidence
wherever the command happened to run. A string trusted_relays (easy to
hand-write without brackets) iterated as characters, failing on '1'
with an error naming nothing findable in the file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
The IOC assembly needed each URL in two forms: redacted for the report,
original to recognise a suspect token, since by the time a query value
reads REDACTED there is nothing left to look at. That arrived as a second
scanner rebuilding a {redacted: original} mapping by repeating urls()'s
own walk, which is two functions that have to stay in step by hand.
One private _scan_urls() returns both forms instead. urls() keeps its
signature and is now a sort over its keys, and the two forms cannot drift
because nothing derives one from the other twice.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
The query string was not the only place a recipient identifier can hide.
A fragment (#e=victim@...) is published as-is since we report the URL's
literal text, not what a browser would send. Userinfo (user:pass@host)
leaks a credential as well as an identifier, so it is stripped outright
rather than redacted in place. A path segment can also smuggle an
encoded query (%3Fe=victim@...); suspect_path_segments now flags a
segment that decodes to something containing '=' or '@', still leaving
the decision to redact or not to human review.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Four hand-written messages using example.org, example.invalid and the
RFC 5737 documentation IP ranges. No real phishing sample goes in this
repository: it would carry the recipient identifiers this tool exists to
keep out of reports, and a repository is potentially public.
forged-chain.eml is the one that matters. The attacker prepends two
Received headers naming an innocent third party, so a parser that walks
past the trust boundary reports 198.51.100.7 rather than 203.0.113.99.
Weekdays verified with date(1) rather than written from memory, since an
RFC2822 parser validates the day against the date and a wrong one is
indistinguishable from a malformed header.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Its SPF is a tree of includes rather than a flat list: spf.privateemail.com
carries no addresses at all, only includes, and one branch nests a further
level. Two of the branches live on jellyfish.systems. The entry here is the
flattened union of the five leaf records, deduplicated, 18 networks.
The re-verification command lists the leaves rather than the top-level
name, and a note says why: querying spf.privateemail.com and finding no
ip4 entries looks like a stale record and is not one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
Every range in the first draft was wrong. They had been written from
memory, and the correct source is each provider's own SPF record, which
is the list of addresses it declares it sends from.
Gmail is the clearest case: the table claimed eleven IPv4 ranges and
_spf.google.com publishes two. Fastmail, Proton and the rest were wrong
in the same way. Outlook and Zoho are added since they were queried
anyway, and the transcription commands are recorded in a comment so the
next check is a copy-paste rather than a search.
Google's goog.json is deliberately NOT the source, though it looks like
one: it lists all Google infrastructure, over a hundred ranges, and using
it would trust every Google-hosted service as part of the user's own mail
path. _spf.google.com is the mail-sending answer.
The table is IPv4 only. An IPv6 hop from one of these providers does not
match and the user is asked instead, which is the safe direction: a hop
wrongly trusted means the real sender is never reported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
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
|
|
A rate limit tells us when, not merely that: a 429 carries Retry-After or
the vendor's reset headers. Recording the deadline and asking the user to
run submit again throws that away and relies on them remembering.
So `abusectl retry` scans every case for destinations whose retry_after
has passed and sends only those, as one unattended cron line beside
mailsync.sh. No inline retry: submit never sleeps waiting for a window,
because a daily quota resets in hours and a process killed while
sleeping is back to the user remembering. One mechanism, not two.
Unattended retry makes three properties load-bearing, since a retry that
re-sends is a duplicate abuse report and cannot be withdrawn. Status is
written before the attempt, so a crash mid-send leaves in-flight, which
is honest, rather than looking like it never happened; retry never
touches in-flight. Attempts are capped, so a dead abuse mailbox stops
being retried. A soft failure with no server deadline gets exponential
backoff.
deferred and failed are separate statuses: deferred means the tool will
handle it, failed means the user must. Collapsing them either strands a
rate-limited report forever or retries a dead mailbox indefinitely.
The qtmaildir dialog accordingly grows no retry button. A deferred
destination belongs to cron, and a button beside it would race the
scheduled run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|
|
An abuse reporting sidecar for phishing mail: parse a flagged message,
extract its indicators, resolve abuse contacts, and file the result to a
MISP instance and to public abuse channels.
This is the umbrella spec, agreed in one design session. Each part gets
its own spec before it is built; this settles what the parts share and
what would be expensive to change later: the case directory and its
manifest format, the redaction rule, the ordering between MISP and the
vendors, and how partial failure is recorded.
It exists as a separate tool because qtmaildir does no network protocol
work by design, and this needs RDAP, three vendor APIs and mail to abuse
desks. qtmaildir invokes it by name the way it invokes mailsync.sh, and
hosts the review dialog; the two are coupled only by the manifest format
and a command name in config.
Two properties are recorded as safety properties rather than
preferences. Recipient identifiers are never captured, at extraction
rather than at submission, so the tool cannot disclose an identifier it
was never given; tracking tokens inside URLs are covered, since a
parameter value is frequently the recipient's address. And nothing
remote is fetched while parsing, because following a link confirms the
address is live and fires the tracker.
parse is the first part to build: stdlib only, no network, no config,
and its output is the format every other part reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
|