Email-Abuse-Investigator

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

      (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

Changes  view on Meta::CPAN

    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 )