Email-Abuse-Investigator
view release on metacpan or search on metacpan
(both https and mailto: variants); Route 3 preemption documented.
- form_contacts() Route 4 bare address (no angle-brackets) in From:.
- abuse_contacts() From: with display-name only (no email addr) skip path.
- risk_assessment() cached-result path (second call returns same hashref).
- form_contacts() Route 3 registrar form-only (markmonitor.com via WHOIS).
- abuse_contacts() Route 3 form-only registrar suppressed from email list.
Total: 186 tests (up from 151).
- Added t/mutant_killers.t: 49 subtests explicitly targeting HIGH and MEDIUM
difficulty survivors from xt/mutant_20260612_003229.t. Covers all six
survivor classes with precise boundary and branch engineering:
- NUM_BOUNDARY (6 subtests): all three risk score thresholds (SCORE_HIGH=9,
SCORE_MEDIUM=5, SCORE_LOW=2) tested at boundary and boundary-1; TZ offset
boundaries (+14:00/â12:00) at exact limit and one-minute over; date skew
(±8 days flags, ±6 days clean); domain expiry "expires soon" vs "expired"
boundary tested with UTC-anchored gmtime dates to prevent timezone drift.
- COND_INV (38 subtests): both true and false branches exercised for every
conditional in _risk_check_origin (residential rDNS, absent rDNS, low
confidence, high-spam-country), _risk_check_auth (SPF/DKIM/DMARC flags),
_risk_check_date (missing date, TZ regex, implausible_timezone guard),
_risk_check_identity (display-name spoof, free webmail, Reply-To mismatch,
undisclosed recipients, encoded subject), _risk_check_urls_and_domains
(URL shortener, plain HTTP, recently_registered, domain expiry, lookalike
domain), abuse_report_text() flag/IP/contact/form sections, and
_compute_abuse_contacts() dedup and all six contact routes.
- BOOL_NEGATE (5 subtests): parse_email(), originating_ip(), all_domains(),
unresolved_contacts(), and risk_assessment() return exact typed values.
- COND_INV_1094_3: unresolved_contacts() unless($dom) domain-extraction path;
killed by injecting a contact whose address domain covers a mailto domain
(source set to Reply-To: to bypass the spoofable-header skip at line 1125).
- BOOL_NEGATE_596_2: new() always returns a distinct blessed object regardless
of CHI cache state.
- Overall test count: 968 tests across 18 files (up from 920).
[Code Quality]
- Removed dead-code double-dereference block in parse_email(). A second
`if (ref $text eq 'SCALAR')` branch was unreachable because the preceding
line had already dereferenced $text. Replaced with a single clean
dereference followed by a croak on any remaining non-scalar reference.
- Added =head1 LIMITATIONS POD section documenting eight known constraints:
no charset conversion, hand-rolled MIME parser, IPv4-only CIDR matching,
WHOIS rate-limiting, non-thread-safe class-level cache, DMARC policy not
fetched, routing logic duplication risk, and CHI cache as a global.
- Fixed all Perl::Critic severity-5 findings: replaced every `return undef`
with bare `return` across _extract_ip_from_received, _reverse_dns,
_domain_whois, _raw_whois, _provider_abuse_for_host,
_provider_abuse_for_ip, _registrable, _header_value, _parse_date_to_epoch,
and _parse_rfc2822_date.
- Fixed Perl::Critic severity-3 findings: converted all `unless` with
comparison operators to negated `if`; moved capture variables inside
their conditionals in _rdap_lookup, _parse_auth_results, and
_parse_whois_text; replaced multi-statement map block with a for loop.
- Removed two hard-tab characters introduced in earlier edits (Object::Configure
overlay comment on line ~643; logger condition in _debug).
0.09 Fri May 15 08:24:33 EDT 2026
[Bug Fixes]
- Fixed t/function.t section 28 (_analyse_domain) failing after
2026-05-14: the WHOIS stub used hardcoded creation and expiry dates
(2025-11-15 and 2026-11-15) that fell outside the 180-day
recently_registered window and caused the registered/expiry
assertions to fail. Replaced with dynamic dates computed at
runtime (10 days ago and 365 days from now) and changed the
assertions to match against the computed values rather than
literal strings. Also escaped the @ in the stub registrar abuse
address inside the interpolating heredoc.
0.08 Wed May 13 19:05:54 EDT 2026
[Enhancements]
- Added test dashboard at https://nigelhorne.github.io/Email-Abuse-Investigator/coverage/
- Added fuzz testing
- domain_expires_soon logic was broken, since it ignored the seconds field
- Added -i / --interactive flag to submit_abuse_report.pl, modelled on
rm -i. When set, the script prompts for confirmation before sending
each abuse report. The recipient address and the reason for contacting
them (the role string) are displayed, followed by a "Send? [y/N]"
prompt. Only reports answered with "y" are sent; all others are
skipped and noted in the output but not counted as failures. Has no
effect in --dry-run mode. Reads confirmation from /dev/tty directly
via a new _read_tty() helper sub so the prompt works correctly when
the email is supplied on stdin, and so the prompt logic is testable
without a pseudo-terminal.
- Added t/submit_script.t: a new black-box test suite for
submit_abuse_report.pl that runs the script as a subprocess via
IPC::Open3. Covers --dry-run output, --interactive with --dry-run
(no prompt shown), the no-contacts unresolved domain listing, spoofed
From: domain exclusion from the unresolved list, and --help
documentation of --interactive. The suite skips automatically if the
script's dependencies are not installed, so it is safe to add to the
distribution without breaking CI on minimal Perl installs.
- Added abuse contacts for major URL shorteners to %PROVIDER_ABUSE.
Previously URL shorteners were only flagged as a risk indicator;
the module fell back to WHOIS which returned the registrar (e.g.
Gandi for is.gd), who correctly responded that they have no control
over how the shortener service is used. The shortener operator is
the right contact. Entries added: is.gd, bit.ly/bitly.com,
tinyurl.com, ow.ly (Hootsuite), buff.ly (Buffer), rb.gy, cutt.ly,
shorturl.at.
- Added major delivery company domains (fedex.com, ups.com, dhl.com,
usps.com, royalmail.com) to %TRUSTED_DOMAINS. These domains appear
as URL hosts in delivery-impersonation spam (the spammer links to
the real fedex.com to make the message look legitimate) but the
delivery company is the victim of impersonation, not a party to
report. Their registrar (CSC Global) was generating false positive
multiple messages processed in the same run. A shared in-memory CHI
instance (TTL 1 hour) is initialised on first call to new() when CHI
is installed. IP WHOIS results are cached under "whois_ip:$ip",
domain analysis under "dom:$domain", and DNS resolution under
"resolve:$host". Failed DNS lookups are cached as an empty string
and returned as undef on subsequent calls so the resolver is not
retried. Gracefully degrades to the existing per-message cache when
CHI is not installed.
- Added IPv6 support throughout. The @PRIVATE_RANGES table now covers
fe80::/10 (link-local), 2001:db8::/32 (RFC 3849 documentation range),
and 64:ff9b::/96 (NAT64 well-known prefix), in addition to the
loopback and ULA ranges already present. @RECEIVED_IP_RE now includes
a bracketed IPv6 pattern so IPv6 addresses are extracted from
Received: headers. _extract_ip_from_received() accepts colon-
containing addresses without IPv4 validation. _resolve_host() tries
an AAAA query after a failed A query when Net::DNS is available.
_raw_whois() uses IO::Socket::IP (dual-stack) in preference to
IO::Socket::INET when that module is installed.
- Added multipart recursion guard. _decode_multipart() now accepts a
$depth parameter (starting at 0 from _split_message()). When depth
reaches MAX_MULTIPART_DEPTH (Readonly constant, value 20) the method
carps and returns immediately rather than recursing further, preventing
stack exhaustion on pathological crafted messages with deeply nested
MIME structures.
- Added Domain::PublicSuffix support to _registrable(). When
Domain::PublicSuffix is installed, get_root_domain() is used for
accurate eTLD+1 normalisation covering the full Public Suffix List.
The existing heuristic (handling co.uk, com.au, and similar common
two-label ccTLD second-levels) is retained as a fallback when the
module is absent.
- Added parallel DNS resolution via AnyEvent::DNS. When AnyEvent::DNS
is installed and a message contains more than one unique URL hostname,
_extract_and_resolve_urls() fires all A queries concurrently via a
condvar and pre-populates the host cache before the sequential
enrichment loop runs. Falls back transparently to sequential
resolution when AnyEvent::DNS is not installed or the host list
contains only one entry.
- Added input sanitisation to parse_email(). The raw message text is
stripped of characters outside [\x09\x0A\x0D\x20-\x7E\x80-\xFF]
(i.e. C0 controls other than tab, LF, and CR, and the DEL character)
before storage in _raw and header parsing. High bytes (0x80-0xFF)
are preserved to avoid corrupting valid UTF-8 content in headers and
bodies.
- Added _sanitise_output() private function. Strips C0 control
characters (0x01-0x08, 0x0B, 0x0C, 0x0E-0x1F) and DEL (0x7F) from
any user-derived string before it is written to report() or
abuse_report_text() output. Tabs, LF, and CR are preserved. High
bytes (0x80-0xFF) are preserved for UTF-8 content. Applied to all
user-derived fields: IP info, organisation names, registrar names,
flag detail strings, and header values.
- Added Object::Configure integration to new(). The constructor now
calls Object::Configure::configure($class, $params) after parameter
validation, allowing per-class defaults to be loaded from a
configuration file. The returned hashref overlays the caller-supplied
parameters before the object is blessed.
- Added new Readonly constants: $MAX_MULTIPART_DEPTH (20),
$CACHE_TTL_SECS (3600), $DEFAULT_TIMEOUT (10), $WHOIS_PORT (43),
$WHOIS_READ_CHUNK (4096), $WHOIS_RAW_MAX (2048), $RECENT_REG_DAYS
(180), $EXPIRY_WARN_DAYS (30), $SECS_PER_DAY (86400),
$DATE_SKEW_DAYS (7), $TZ_MAX_POS_MINS (840), $TZ_MAX_NEG_MINS (720),
$SCORE_HIGH (9), $SCORE_MEDIUM (5), $SCORE_LOW (2), %FLAG_WEIGHT,
$ROLE_MAX_LEN (80), $ROLE_WRAP_LEN (66). All previously magic
numbers have been removed from the code body.
[Bug Fixes]
- Fixed NumericBoundary mutator in SchemaExtractor incorrectly treating
the < operator in file open (open $fh, '<', $path) and readline
(<$fh>) expressions as numeric comparisons, generating spurious
mutant variants for those operators.
- Wrap in eval to catch 'Connection reset by peer' thrown by Fatal/autodie
0.07 Mon Mar 30 08:45:04 EDT 2026
Bug fixes
- Added Route 7 to abuse_contacts(): reply addresses found in the message
body are now looked up in the provider table and generate abuse contacts.
This catches a common advance-fee and investment scam pattern where the
From: and Return-Path: headers use a spoofed or compromised address (the
innocent victim of the fraud), while the real contact address -- a free
webmail account chosen by the spammer -- is mentioned explicitly in the
body text, e.g. "contact profcindyinvestments@hotmail.com for details".
The %TRUSTED_DOMAINS filter is intentionally bypassed for this route:
being hosted on a trusted provider (Hotmail, Gmail) is what makes these
addresses actionable, since the spammer chose free webmail precisely
because it is accessible and anonymous. Recipient domains (To:, Cc:)
are still excluded. Only domains present in %PROVIDER_ABUSE generate
contacts via this route; unknown domains in the body are ignored.
The role string includes the specific address found, e.g.
"Reply address in body (profcindyinvestments@hotmail.com)", so the
abuse desk knows exactly which account to investigate.
- Fixed abuse_contacts() generating registrar contacts for innocent domains
that appear only in spoofable sending headers (From:, Return-Path:,
Sender:). These headers are trivially forged; when a spammer uses a
victim's address as the envelope sender, reporting the victim's domain
registrar is both unhelpful and potentially harmful to the innocent party.
The registrar contact is now suppressed when the domain's only source is
one of these three headers AND the same domain does not also appear as a
URL host. If the From: domain appears in a URL as well, the spammer
controls it and the registrar contact is retained. Domains sourced from
Reply-To:, DKIM-Signature:, List-Unsubscribe:, Message-ID:, or the
message body are unaffected -- those all indicate deliberate spammer
choice. Discovered via a real advance-fee spam where qwestoffice.net
was spoofed as the sender but profcindyinvestments@hotmail.com was the
actual reply address, causing a false report to CSC Global (registrar
of qwestoffice.net).
- Fixed abuse_contacts() generating spurious account-provider contacts
from SRS-rewritten Return-Path: and Sender: headers. SRS (Sender
Rewriting Scheme) addresses are generated by mail forwarders to preserve
( run in 1.015 second using v1.01-cache-2.11-cpan-7fcb06a456a )