TER1 (Statement): 97.72%
TER2 (Branch): 68.05%
TER3 (LCSAJ): 94.4% (117/124)
Approximate LCSAJ segments: 411
● Covered — this LCSAJ path was executed during testing.
● Not covered — this LCSAJ path was never executed. These are the paths to focus on.
Multiple dots on a line indicate that multiple control-flow paths begin at that line. Hovering over any dot shows:
start → end → jump
Uncovered paths show [NOT COVERED] in the tooltip.
1: package Email::Abuse::Investigator; 2: 3: use strict; 4: use warnings; 5: use autodie qw(:all); 6: 7: use Carp qw(croak carp); 8: use IO::Select; 9: use IO::Socket::INET; 10: BEGIN { $Sub::Private::config{mode} = 'enforce' } 11: use Sub::Private; 12: use Sub::Protected; 13: use MIME::QuotedPrint qw( decode_qp ); 14: use MIME::Base64 qw( decode_base64 ); 15: use Object::Configure; 16: use Params::Get; 17: use Params::Validate::Strict 0.34; 18: use Readonly; 19: use Readonly::Values::Months; 20: use Socket qw( inet_aton inet_ntoa AF_INET ); 21: use Time::Piece; 22: 23: =head1 NAME 24: 25: Email::Abuse::Investigator - Analyse spam email to identify originating hosts, 26: hosted URLs, and suspicious domains 27: 28: =head1 VERSION 29: 30: Version 0.12 31: 32: =cut 33: 34: our $VERSION = '0.12'; 35: 36: =head1 SYNOPSIS 37: 38: use Email::Abuse::Investigator; 39: 40: my $analyser = Email::Abuse::Investigator->new( verbose => 1 ); 41: $analyser->parse_email($raw_email_text); 42: 43: # Originating IP and its network owner 44: my $origin = $analyser->originating_ip(); 45: 46: # All HTTP/HTTPS URLs found in the body 47: my @urls = $analyser->embedded_urls(); 48: 49: # All domains extracted from mailto: links and bare addresses in the body 50: my @mdoms = $analyser->mailto_domains(); 51: 52: # All domains mentioned anywhere (union of the above) 53: my @adoms = $analyser->all_domains(); 54: 55: # Full printable report 56: print $analyser->report(); 57: 58: =head1 DESCRIPTION 59: 60: C<Email::Abuse::Investigator> examines the raw source of a spam/phishing e-mail 61: and answers the questions manual abuse investigators ask: 62: 63: =over 4 64: 65: =item 1. Where did the message really come from? 66: 67: Walks the C<Received:> chain, skips private/trusted IPs, and identifies the 68: first external hop. Enriches with rDNS, WHOIS/RDAP org name and abuse 69: contact. Both IPv4 and IPv6 addresses are supported. 70: 71: =item 2. Who hosts the advertised web sites? 72: 73: Extracts every C<http://> and C<https://> URL from both plain-text and HTML 74: parts, resolves each hostname to an IP, and looks up the network owner. 75: 76: =item 3. Who owns the reply-to / contact domains? 77: 78: Extracts domains from C<mailto:> links, bare e-mail addresses in the body, 79: the C<From:>/C<Reply-To:>/C<Sender:>/C<Return-Path:> headers, C<DKIM-Signature: d=> 80: (the signing domain), C<List-Unsubscribe:> (the ESP or bulk-sender domain), and the 81: C<Message-ID:> domain. For each unique domain it gathers: 82: 83: =over 8 84: 85: =item * Domain registrar and registrant (WHOIS) 86: 87: =item * Web-hosting IP and network owner (A record -> RDAP) 88: 89: =item * Mail-hosting IP and network owner (MX record -> RDAP) 90: 91: =item * DNS nameserver operator (NS record -> RDAP) 92: 93: =item * Whether the domain was recently registered (potential flag) 94: 95: =back 96: 97: =back 98: 99: =cut 100: 101: # ----------------------------------------------------------------------- 102: # Optional modules -- gracefully degraded when absent 103: # ----------------------------------------------------------------------- 104: 105: # Net::DNS enables MX, NS, AAAA lookups; falls back to gethostbyname 106: my $HAS_NET_DNS; 107: 108: # LWP::UserAgent enables RDAP queries; falls back to raw WHOIS 109: my $HAS_LWP; 110: my $HAS_CONN_CACHE; 111: 112: # HTML::LinkExtor enables structural HTML link extraction 113: my $HAS_HTML_LINKEXTOR; 114: 115: # CHI enables a persistent cross-message cache for IP/domain data 116: my $HAS_CHI; 117: 118: # IO::Socket::IP provides dual-stack (IPv4+IPv6) socket support 119: my $HAS_IO_SOCKET_IP; 120: 121: # Domain::PublicSuffix enables accurate eTLD+1 normalisation 122: my $HAS_PUBLIC_SUFFIX; 123: 124: # AnyEvent::DNS enables parallel DNS queries 125: my $HAS_ANYEVENT_DNS; 126: 127: BEGIN { 128: $HAS_NET_DNS = eval { require Net::DNS; 1 }; 129: $HAS_LWP = eval { require LWP::UserAgent; 1 }; 130: $HAS_CONN_CACHE = eval { require LWP::ConnCache; 1 }; 131: $HAS_HTML_LINKEXTOR= eval { require HTML::LinkExtor; 1 }; 132: $HAS_CHI = eval { require CHI; 1 }; 133: $HAS_IO_SOCKET_IP = eval { require IO::Socket::IP; 1 }; 134: $HAS_PUBLIC_SUFFIX = eval { require Domain::PublicSuffix; 1 }; 135: $HAS_ANYEVENT_DNS = eval { require AnyEvent::DNS; 1 }; 136: } 137: 138: # ----------------------------------------------------------------------- 139: # Constants -- all magic numbers and strings live here 140: # ----------------------------------------------------------------------- 141: 142: # WHOIS protocol port (IANA-assigned) 143: Readonly::Scalar my $WHOIS_PORT => 43; 144: 145: # Bytes to read per sysread() call from a WHOIS socket 146: Readonly::Scalar my $WHOIS_READ_CHUNK => 4096; 147: 148: # Maximum WHOIS response bytes stored in whois_raw (keep reports compact) 149: Readonly::Scalar my $WHOIS_RAW_MAX => 2048; 150: 151: # Maximum multipart nesting depth (recursion guard -- RFC 2046 has no limit 152: # but we cap it to prevent stack exhaustion on crafted messages) 153: Readonly::Scalar my $MAX_MULTIPART_DEPTH => 20; 154: 155: # Number of days before registration that triggers recently_registered flag 156: Readonly::Scalar my $RECENT_REG_DAYS => 180; 157: 158: # Number of days ahead of expiry that triggers domain_expires_soon flag 159: Readonly::Scalar my $EXPIRY_WARN_DAYS => 30; 160: 161: # Seconds in a day -- used in date arithmetic throughout 162: Readonly::Scalar my $SECS_PER_DAY => 86400; 163: 164: # Suspicious date window: dates outside +/- 7 days raise a flag 165: Readonly::Scalar my $DATE_SKEW_DAYS => 7; 166: 167: # Maximum positive timezone offset in minutes (+14:00 = Line Islands) 168: Readonly::Scalar my $TZ_MAX_POS_MINS => 840; 169: 170: # Maximum negative timezone offset in minutes (-12:00 = Baker Island) 171: Readonly::Scalar my $TZ_MAX_NEG_MINS => 720; 172: 173: # High-risk score threshold 174: Readonly::Scalar my $SCORE_HIGH => 9; 175: 176: # Medium-risk score threshold 177: Readonly::Scalar my $SCORE_MEDIUM => 5; 178: 179: # Low-risk score threshold 180: Readonly::Scalar my $SCORE_LOW => 2; 181: 182: # Flag severity weights (contribute to the numeric risk score) 183: Readonly::Hash my %FLAG_WEIGHT => ( 184: HIGH => 3, 185: MEDIUM => 2, 186: LOW => 1, 187: INFO => 0, 188: ); 189: 190: # Maximum merged-role display string length before summarisation kicks in 191: Readonly::Scalar my $ROLE_MAX_LEN => 80; 192: 193: # CHI cache TTL in seconds (1 hour -- IP allocations change slowly) 194: Readonly::Scalar my $CACHE_TTL_SECS => 3600; 195: 196: # Default constructor timeout for network operations (seconds) 197: Readonly::Scalar my $DEFAULT_TIMEOUT => 10; 198: 199: # Maximum role string length before truncation 200: Readonly::Scalar my $ROLE_WRAP_LEN => 66; 201: 202: # Maximum redirect hops to follow when resolving shortener/redirect-cloaker URLs 203: Readonly::Scalar my $REDIRECT_MAX_HOPS => 3; 204: 205: # Brand names checked in lookalike-domain detection. 206: # Overridable at runtime via Object::Configure. 207: Readonly::Array my @LOOKALIKE_BRANDS => qw( 208: paypal apple google amazon microsoft netflix ebay 209: instagram facebook twitter linkedin bankofamerica 210: wellsfargo chase barclays hsbc lloyds santander 211: ); 212: 213: # ----------------------------------------------------------------------- 214: # Private ranges -- IPs that are never actionable abuse targets 215: # ----------------------------------------------------------------------- 216: 217: # Both IPv4 and IPv6 private/reserved ranges. Each entry is a compiled 218: # regex; _is_private() iterates over them and returns true on first match. 219: my @PRIVATE_RANGES = ( 220: # IPv4 ranges 221: qr/^0\./, # 0.0.0.0/8 this-network (RFC 1122) 222: qr/^127\./, # 127.0.0.0/8 loopback 223: qr/^10\./, # 10.0.0.0/8 RFC 1918 224: qr/^192\.168\./, # 192.168.0.0/16 RFC 1918 225: qr/^172\.(?:1[6-9]|2\d|3[01])\./, # 172.16.0.0/12 RFC 1918 226: qr/^169\.254\./, # 169.254.0.0/16 link-local 227: qr/^100\.(?:6[4-9]|[7-9]\d|1(?:[01]\d|2[0-7]))\./, # 100.64.0.0/10 CGN (RFC 6598) 228: qr/^192\.0\.0\./, # 192.0.0.0/24 IETF protocol (RFC 6890) 229: qr/^192\.0\.2\./, # 192.0.2.0/24 TEST-NET-1 (RFC 5737) 230: qr/^198\.51\.100\./, # 198.51.100.0/24 TEST-NET-2 (RFC 5737) 231: qr/^203\.0\.113\./, # 203.0.113.0/24 TEST-NET-3 (RFC 5737) 232: qr/^255\./, # 255.0.0.0/8 broadcast 233: # IPv6 ranges 234: qr/^::1$/, # IPv6 loopback 235: qr/^fe80:/i, # IPv6 link-local (fe80::/10) 236: qr/^fc/i, # IPv6 ULA fc00::/7 237: qr/^fd/i, # IPv6 ULA fd00::/8 238: qr/^2001:db8:/i, # IPv6 documentation range (RFC 3849) 239: qr/^64:ff9b:/i, # IPv6 NAT64 well-known prefix 240: ); 241: 242: # Priority-ordered patterns for extracting IPs from Received: headers. 243: # Covers bracketed IPv4, bracketed IPv6, parenthesised address, and bare dotted-quad. 244: my @RECEIVED_IP_RE = ( 245: qr/\[\s*([\d.]+)\s*\]/, # [1.2.3.4] 246: qr/\[\s*([0-9a-fA-F:]+)\s*\]/, # [IPv6 address] 247: qr/\(\s*[\w.-]*\s*\[?\s*([\d.]+)\s*\]?\s*\)/, # (hostname [1.2.3.4]) 248: qr/from\s+[\w.-]+\s+([\d.]+)/, # from hostname addr 249: qr/([\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}\.[\d]{1,3})/, # bare dotted-quad fallback 250: ); 251: 252: # ----------------------------------------------------------------------- 253: # Default configuration -- overridable via Object::Configure 254: # ----------------------------------------------------------------------- 255: 256: # Object::Configure may overlay 257: # values from a file before new() uses them. Use Readonly for constants 258: # that should never be overridden at runtime. 259: 260: # ----------------------------------------------------------------------- 261: # Trusted domains (infrastructure -- never report these as abuse targets) 262: # Can be overrideen at runtime by Object::Configure 263: # ----------------------------------------------------------------------- 264: 265: my %TRUSTED_DOMAINS = map { $_ => 1 } qw( 266: gmail.com googlemail.com yahoo.com outlook.com hotmail.com 267: google.com microsoft.com apple.com amazon.com 268: googlegroups.com groups.google.com 269: fonts.googleapis.com fonts.gstatic.com 270: ajax.googleapis.com maps.googleapis.com 271: w3.org 272: fedex.com ups.com dhl.com usps.com royalmail.com 273: ); 274: 275: # ----------------------------------------------------------------------- 276: # URL shortener domains (real destination is hidden behind these) 277: # ----------------------------------------------------------------------- 278: 279: my %URL_SHORTENERS = map { $_ => 1 } qw( 280: bit.ly bitly.com tinyurl.com t.co ow.ly 281: goo.gl is.gd buff.ly ift.tt dlvr.it 282: short.link rebrand.ly tiny.cc cutt.ly rb.gy 283: shorturl.at bl.ink smarturl.it yourls.org clicky.me 284: snip.ly adf.ly bc.vc lnkd.in fb.me 285: youtu.be 286: ); 287: 288: # Cloud object-stores and CDN hosting paths commonly abused to serve redirect 289: # pages that hide the real phishing destination (same evasion as a URL shortener 290: # but using legitimate cloud infrastructure to pass spam filters). 291: # Exact-match hosts; suffix patterns are in @REDIRECT_HOST_SUFFIXES below. 292: my %REDIRECT_HOSTS = map { $_ => 1 } qw( 293: storage.googleapis.com 294: blob.core.windows.net 295: pages.dev 296: firebaseapp.com 297: web.app 298: ); 299: 300: # Subdomain-suffix patterns for bucket-style hosting (e.g. mybucket.s3.amazonaws.com). 301: # Checked by _is_redirect_cloaker() using a suffix match against the bare hostname. 302: Readonly::Array my @REDIRECT_HOST_SUFFIXES => qw( 303: .s3.amazonaws.com 304: .s3-website.amazonaws.com 305: .cloudfront.net 306: .github.io 307: .firebaseapp.com 308: .web.app 309: ); 310: 311: # ----------------------------------------------------------------------- 312: # Well-known provider abuse contacts 313: # Can be overrideen at runtime by Object::Configure 314: # ----------------------------------------------------------------------- 315: 316: # Curated table of provider abuse contacts. Entries with only a 'form' 317: # key (no 'email') require web-form submission; abuse_contacts() suppresses 318: # email addresses for those providers and form_contacts() surfaces them. 319: my %PROVIDER_ABUSE = ( 320: # Google / Gmail 321: 'google.com' => { email => 'abuse@google.com', note => 'Also report Gmail accounts via https://support.google.com/mail/contact/abuse' }, 322: 'gmail.com' => { email => 'abuse@google.com', note => 'Report Gmail spam via https://support.google.com/mail/contact/abuse' }, 323: 'googlemail.com' => { email => 'abuse@google.com', note => 'Report via https://support.google.com/mail/contact/abuse' }, 324: '1e100.net' => { email => 'abuse@google.com', note => 'Google infrastructure' }, 325: 'blogspot.com' => { email => 'abuse@google.com', note => 'Blogger/Blogspot -- report via https://support.google.com/blogger/answer/76315' }, 326: 'blogger.com' => { email => 'abuse@google.com', note => 'Blogger platform abuse' }, 327: 'sites.google.com' => { email => 'abuse@google.com', note => 'Google Sites hosted content' }, 328: 'storage.googleapis.com' => { email => 'google-cloud-compliance@google.com', note => 'Google Cloud Storage bucket abuse -- also report via https://support.google.com/code/go/gce_abuse_report' }, 329: # Microsoft 330: 'microsoft.com' => { email => 'abuse@microsoft.com', note => 'Also report via https://www.microsoft.com/en-us/wdsi/support/report-unsafe-site' }, 331: 'outlook.com' => { email => 'abuse@microsoft.com', note => 'Report Outlook spam: https://support.microsoft.com/en-us/office/report-phishing' }, 332: 'hotmail.com' => { email => 'abuse@microsoft.com', note => 'Report via https://support.microsoft.com/en-us/office/report-phishing' }, 333: 'live.com' => { email => 'abuse@microsoft.com', note => 'Microsoft consumer mail' }, 334: 'office365.com' => { email => 'abuse@microsoft.com', note => 'Microsoft 365 infrastructure' }, 335: 'protection.outlook.com' => { email => 'abuse@microsoft.com', note => 'Microsoft EOP gateway' }, 336: # Yahoo 337: 'yahoo.com' => { email => 'abuse@yahoo-inc.com', note => 'Also use https://io.help.yahoo.com/contact/index' }, 338: 'yahoo.co.uk' => { email => 'abuse@yahoo-inc.com', note => 'Yahoo UK' }, 339: # Apple 340: 'apple.com' => { email => 'reportphishing@apple.com', note => 'iCloud / Apple Mail abuse' }, 341: 'icloud.com' => { email => 'reportphishing@apple.com', note => 'iCloud abuse' }, 342: 'me.com' => { email => 'reportphishing@apple.com', note => 'Apple legacy mail' }, 343: # Amazon / AWS 344: 'amazon.com' => { email => 'abuse@amazonaws.com', note => 'Also https://aws.amazon.com/forms/report-abuse' }, 345: 'amazonaws.com' => { email => 'abuse@amazonaws.com', note => 'AWS abuse form: https://aws.amazon.com/forms/report-abuse' }, 346: 'amazonses.com' => { email => 'abuse@amazonaws.com', note => 'Amazon SES sending infrastructure' }, 347: # Cloudflare 348: 'cloudflare.com' => { email => 'abuse@cloudflare.com', note => 'Report via https://www.cloudflare.com/abuse/' }, 349: # Fastly / Akamai 350: 'fastly.net' => { email => 'abuse@fastly.com', note => 'Fastly CDN' }, 351: 'akamai.com' => { email => 'abuse@akamai.com', note => 'Akamai CDN' }, 352: 'akamaitechnologies.com' => { email => 'abuse@akamai.com', note => 'Akamai CDN' }, 353: # Namecheap 354: 'namecheap.com' => { email => 'abuse@namecheap.com', note => 'Registrar abuse' }, 355: # GoDaddy -- web form only; email bounces 356: 'godaddy.com' => { 357: form => 'https://supportcenter.godaddy.com/AbuseReport', 358: form_paste => 'Select the abuse type (spam, phishing, malware etc). ' 359: . 'Enter the domain name in the Domain field. ' 360: . 'Paste the originating IP, risk flags, and the relevant ' 361: . 'Received: headers from the report below.', 362: form_upload => 'Take a screenshot of the report as a .png or .jpg, ' 363: . 'or export it as a .pdf.', 364: note => 'Registrar/host -- email reports not monitored, use web form', 365: }, 366: # SendGrid / Twilio 367: 'sendgrid.net' => { email => 'abuse@sendgrid.com', note => 'ESP -- include full headers' }, 368: 'sendgrid.com' => { email => 'abuse@sendgrid.com', note => 'ESP -- include full headers' }, 369: # Mailchimp / Mandrill 370: 'mailchimp.com' => { email => 'abuse@mailchimp.com', note => 'ESP abuse' }, 371: 'mandrillapp.com' => { email => 'abuse@mailchimp.com', note => 'Mandrill transactional ESP' }, 372: # OVH 373: 'ovh.net' => { email => 'abuse@ovh.net', note => 'OVH hosting' }, 374: 'ovh.com' => { email => 'abuse@ovh.com', note => 'OVH hosting' }, 375: # Hetzner 376: 'hetzner.com' => { email => 'abuse@hetzner.com', note => 'Hetzner hosting' }, 377: # Digital Ocean 378: 'digitalocean.com' => { email => 'abuse@digitalocean.com',note => 'DO abuse form: https://www.digitalocean.com/company/contact/#abuse' }, 379: # Linode / Akamai 380: 'linode.com' => { email => 'abuse@linode.com', note => 'Linode/Akamai Cloud' }, 381: # Constant Contact 382: 'constantcontact.com' => { email => 'abuse@constantcontact.com', note => 'ESP abuse' }, 383: 'r.constantcontact.com' => { email => 'abuse@constantcontact.com', note => 'Constant Contact sending infrastructure' }, 384: # HubSpot 385: 'hubspot.com' => { email => 'abuse@hubspot.com', note => 'ESP abuse' }, 386: 'hs-analytics.net' => { email => 'abuse@hubspot.com', note => 'HubSpot analytics infrastructure' }, 387: # Campaign Monitor 388: 'createsend.com' => { email => 'abuse@campaignmonitor.com', note => 'Campaign Monitor ESP' }, 389: 'cmail20.com' => { email => 'abuse@campaignmonitor.com', note => 'Campaign Monitor sending infrastructure' }, 390: # Klaviyo 391: 'klaviyo.com' => { email => 'abuse@klaviyo.com', note => 'ESP abuse' }, 392: # Brevo (formerly Sendinblue) 393: 'sendinblue.com' => { email => 'abuse@sendinblue.com', note => 'ESP abuse' }, 394: 'brevo.com' => { email => 'abuse@brevo.com', note => 'ESP abuse' }, 395: # Mailgun 396: 'mailgun.com' => { email => 'abuse@mailgun.com', note => 'ESP abuse' }, 397: 'mailgun.org' => { email => 'abuse@mailgun.com', note => 'Mailgun sending infrastructure' }, 398: # Postmark 399: 'postmarkapp.com' => { email => 'abuse@postmarkapp.com', note => 'ESP abuse' }, 400: # WordPress.com 401: 'wordpress.com' => { email => 'abuse@wordpress.com', note => 'WordPress.com hosted blog -- report via https://en.wordpress.com/abuse/' }, 402: 'wp.com' => { email => 'abuse@wordpress.com', note => 'WordPress.com short domain' }, 403: # Substack 404: 'substack.com' => { email => 'abuse@substack.com', note => 'Substack newsletter platform abuse' }, 405: # ActiveCampaign 406: 'activecampaign.com' => { email => 'abuse@activecampaign.com', note => 'ActiveCampaign ESP' }, 407: 'ac-tinker.com' => { email => 'abuse@activecampaign.com', note => 'ActiveCampaign tracking infrastructure' }, 408: # Salesforce Marketing Cloud 409: 'salesforce.com' => { email => 'abuse@salesforce.com', note => 'Salesforce Marketing Cloud / ExactTarget ESP' }, 410: 'mc.salesforce.com' => { email => 'abuse@salesforce.com', note => 'Salesforce Marketing Cloud sending infrastructure' }, 411: 'exacttarget.com' => { email => 'abuse@salesforce.com', note => 'ExactTarget / Salesforce Marketing Cloud ESP' }, 412: 'et.exacttarget.com' => { email => 'abuse@salesforce.com', note => 'ExactTarget sending infrastructure' }, 413: # Vultr 414: 'vultr.com' => { email => 'abuse@vultr.com', note => 'Vultr hosting' }, 415: # Contabo 416: 'contabo.com' => { email => 'abuse@contabo.com', note => 'Contabo hosting' }, 417: # Leaseweb 418: 'leaseweb.com' => { email => 'abuse@leaseweb.com', note => 'Leaseweb hosting' }, 419: # M247 420: 'm247.com' => { email => 'abuse@m247.com', note => 'M247 hosting' }, 421: # MarkMonitor -- web form only 422: 'markmonitor.com' => { 423: form => 'https://corp.markmonitor.com/domain/ui/abuse-report', 424: form_paste => 'Complete all fields including the domain name and your ' 425: . 'description of the abuse. Paste the originating IP, ' 426: . 'risk flags, and the relevant Received: headers from the ' 427: . 'report below.', 428: form_upload => 'Take a screenshot of the report as a .png or .jpg, or export it as a .pdf. MarkMonitor does not accept .eml files.', 429: note => 'Brand-protection registrar -- email reports not processed', 430: }, 431: # URL shortener operators 432: 'is.gd' => { email => 'abuse@is.gd', note => 'URL shortener -- report via https://is.gd/contact.php' }, 433: 'bitly.com' => { email => 'abuse@bitly.com', note => 'URL shortener abuse' }, 434: 'bit.ly' => { email => 'abuse@bitly.com', note => 'URL shortener abuse' }, 435: 'tinyurl.com' => { email => 'abuse@tinyurl.com', note => 'URL shortener abuse' }, 436: 'ow.ly' => { email => 'abuse@hootsuite.com', note => 'Hootsuite URL shortener' }, 437: 'buff.ly' => { email => 'abuse@buffer.com', note => 'Buffer URL shortener' }, 438: 'rb.gy' => { email => 'abuse@rb.gy', note => 'URL shortener abuse' }, 439: 'cutt.ly' => { email => 'abuse@cutt.ly', note => 'URL shortener abuse' }, 440: 'shorturl.at' => { email => 'abuse@shorturl.at', note => 'URL shortener abuse' }, 441: # Dynadot -- web form only 442: 'dynadot.com' => { 443: form => 'https://www.dynadot.com/report-abuse', 444: form_paste => 'Complete all fields including the domain name and your ' 445: . 'description of the abuse. Paste the originating IP, ' 446: . 'risk flags, and the relevant Received: headers from the ' 447: . 'report below.', 448: form_upload => 'Take a screenshot of the report as a .png or .jpg, ' 449: . 'or export it as a .pdf.', 450: note => 'Registrar -- email reports not monitored, use web form', 451: }, 452: # Global Domain Group -- web form only 453: 'globaldomaingroup.com' => { 454: form => 'https://globaldomaingroup.com/report-abuse', 455: form_paste => 'Complete all fields including the domain name and your ' 456: . 'description of the abuse. Paste the originating IP, ' 457: . 'risk flags, and the relevant Received: headers from the ' 458: . 'report below.', 459: form_upload => 'Attach the original spam message as an .eml file.', 460: note => 'Registrar -- email reports explicitly not accepted', 461: }, 462: # TPG / Internode (Australia) 463: 'tpgi.com.au' => { email => 'abuse@tpg.com.au', note => 'TPG Telecom Australia' }, 464: 'tpg.com.au' => { email => 'abuse@tpg.com.au', note => 'TPG Telecom Australia' }, 465: 'internode.on.net' => { email => 'abuse@internode.on.net',note => 'Internode Australia' }, 466: ); 467: 468: # ----------------------------------------------------------------------- 469: # Constructor 470: # ----------------------------------------------------------------------- 471: 472: =head1 METHODS 473: 474: =head2 new( %options ) 475: 476: Constructs and returns a new C<Email::Abuse::Investigator> analyser object. The 477: object is stateless until C<parse_email()> is called; all analysis results 478: are stored on the object and retrieved via the public accessor methods 479: documented below. 480: 481: A single object may be reused for multiple emails by calling C<parse_email()> 482: again: all per-message cached state from the previous message is discarded 483: automatically. Cross-message IP and domain lookup results are retained 484: in a shared CHI cache (if C<CHI> is installed) to avoid redundant network 485: queries across messages processed in the same process. 486: 487: =head3 Usage 488: 489: # Minimal -- all options take safe defaults 490: my $analyser = Email::Abuse::Investigator->new(); 491: 492: # With options 493: my $analyser = Email::Abuse::Investigator->new( 494: timeout => 15, 495: trusted_relays => ['203.0.113.0/24', '10.0.0.0/8'], 496: verbose => 0, 497: ); 498: 499: $analyser->parse_email($raw_rfc2822_text); 500: my $origin = $analyser->originating_ip(); 501: my @urls = $analyser->embedded_urls(); 502: my @domains = $analyser->mailto_domains(); 503: my $risk = $analyser->risk_assessment(); 504: my @contacts = $analyser->abuse_contacts(); 505: print $analyser->report(); 506: 507: =head3 Arguments 508: 509: All arguments are optional named parameters passed as a flat key-value list. 510: 511: =over 4 512: 513: =item C<timeout> (integer, default 10) 514: 515: Maximum seconds to wait for any single network operation. Set to 0 to 516: disable timeouts (not recommended for production use). 517: 518: =item C<trusted_relays> (arrayref of strings, default []) 519: 520: IP addresses or CIDR blocks to skip during Received: chain analysis. 521: Each element may be an exact IPv4 address (C<'192.0.2.1'>) or a CIDR 522: block (C<'192.0.2.0/24'>). 523: 524: =item C<verbose> (boolean, default 0) 525: 526: When true, diagnostic messages are written to STDERR. 527: 528: =back 529: 530: =head3 Returns 531: 532: A blessed C<Email::Abuse::Investigator> object. No network I/O is performed 533: during construction. 534: 535: =head3 Side Effects 536: 537: If C<CHI> is installed, a shared in-memory cache is initialised (or 538: re-used if a cache was already created by a prior call to C<new()>). 539: This cache persists for the lifetime of the process. 540: 541: =head3 Notes 542: 543: =over 4 544: 545: =item * 546: 547: Unknown option keys are silently ignored. 548: 549: =item * 550: 551: The object is not thread-safe. Use a separate object per thread. 552: 553: =item * 554: 555: WHOIS read timeouts use C<IO::Select> rather than C<alarm()>, so they 556: work correctly on Windows and in threaded Perl interpreters. 557: 558: =back 559: 560: =head3 API Specification 561: 562: =head4 Input 563: 564: { 565: timeout => { 566: type => 'integer', 567: optional => 1, 568: min => 0, 569: default => 10, 570: }, 571: trusted_relays => { 572: type => 'arrayref', 573: element_type => 'string', 574: optional => 1, 575: default => [], 576: }, 577: verbose => { 578: type => 'boolean', 579: optional => 1, 580: default => 0, 581: }, 582: } 583: 584: =head4 Output 585: 586: { 587: type => 'object', 588: isa => 'Email::Abuse::Investigator', 589: } 590: 591: =cut 592: 593: # Class-level cross-message CHI cache (shared across all instances). 594: # Populated lazily on first call to new() when CHI is available. 595: my $_cache; 596: 597: sub new { ●598 → 625 → 634 598: my $class = shift; 599: 600: # Accept hash or hashref arguments uniformly 601: my $params = Params::Validate::Strict::validate_strict({ 602: args => Params::Get::get_params(undef, \@_) || {}, 603: schema => { 604: timeout => { 605: 'type' => 'integer', 606: 'optional' => 1, 607: 'min' => 0, 608: }, 609: trusted_relays => { 610: 'type' => 'arrayref', 611: 'element_type' => 'string', 612: 'optional' => 1, 613: }, 614: verbose => { 615: 'type' => 'boolean', 616: 'optional' => 1, 617: }, 618: }, 619: }); 620: 621: # Merge in any file-based configuration via Object::Configure 622: $params = Object::Configure::configure($class, $params); 623: 624: # Initialise the cross-message CHI cache on first construction 625: if ($HAS_CHI && !$_cache) {626: $_cache = CHI->new( 627: driver => 'Memory', 628: global => 1, 629: expires_in => $CACHE_TTL_SECS, 630: ); 631: } 632: 633: # Build and bless the object with default slot values 634: return bless {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_625_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes635: timeout => $DEFAULT_TIMEOUT, 636: trusted_relays => [], 637: verbose => 0, 638: _raw => '', 639: _headers => [], 640: _body_plain => '', 641: _body_html => '', 642: _received => [], 643: _origin => undef, 644: _urls => undef, # lazy-computed by embedded_urls() 645: _mailto_domains=> undef, # lazy-computed by mailto_domains() 646: _contacts => undef, # lazy-computed by abuse_contacts() 647: _domain_info => {}, # per-message domain analysis cache 648: _sending_sw => [], # X-Mailer / X-PHP-Originating-Script etc. 649: _rcvd_tracking => [], # per-hop tracking IDs from Received: headers 650: %{$params}, # Overlay Object::Configure and caller-supplied values 651: }, $class; 652: } 653: 654: # ----------------------------------------------------------------------- 655: # Public: parse 656: # ----------------------------------------------------------------------- 657: 658: =head2 parse_email( $text ) 659: 660: Feeds a raw RFC 2822 email message to the analyser and prepares it for 661: subsequent interrogation. This is the only method that must be called 662: before any other public method. 663: 664: If the same object is used for a second message, calling C<parse_email()> 665: again completely replaces all per-message state from the first message. 666: The cross-message CHI cache is B<not> flushed; IP and domain lookups 667: cached from prior messages are retained. 668: 669: =head3 Usage 670: 671: my $raw = do { local $/; <STDIN> }; 672: $analyser->parse_email($raw); 673: 674: # Scalar reference (avoids copying large messages) 675: $analyser->parse_email(\$raw); 676: 677: # Chained 678: my $analyser = Email::Abuse::Investigator->new()->parse_email($raw); 679: 680: =head3 Arguments 681: 682: =over 4 683: 684: =item C<$text> (string or string reference, required) 685: 686: Complete raw RFC 2822 email message, including all headers and the body. 687: Both LF-only and CRLF line endings are accepted. 688: 689: =back 690: 691: =head3 Returns 692: 693: The object itself (C<$self>), enabling method chaining. 694: 695: =head3 Side Effects 696: 697: Parses headers, decodes the body (quoted-printable, base64, multipart), 698: extracts sending-software fingerprints, and populates per-hop tracking 699: data. All previously computed lazy results are discarded. 700: 701: =head3 Notes 702: 703: =over 4 704: 705: =item * 706: 707: If C<$text> is empty or contains no header/body separator, all public 708: methods will return empty/safe values. 709: 710: =item * 711: 712: Decoding errors in base64 or quoted-printable payloads are silenced; raw 713: bytes are used in place of correct output to prevent exceptions. 714: 715: =back 716: 717: =head3 API Specification 718: 719: =head4 Input 720: 721: [ 722: { 723: type => [ 'string', 'stringref' ] 724: }, 725: ] 726: 727: =head4 Output 728: 729: { 730: type => 'object', 731: isa => 'Email::Abuse::Investigator', 732: } 733: 734: =cut 735: 736: # TODO: Allow a Mail::Message object to be passed in 737: sub parse_email { 738: my $self = shift; 739: 740: # Accept both positional string and named 'text' argument 741: my $args = Params::Get::get_params('text', \@_); 742: my $text = $args->{text}; 743: 744: # Dereference a scalar-ref in a single clean pass 745: $text = $$text if ref($text) eq 'SCALAR'; 746: 747: # Any other reference type is a programming error 748: Carp::croak(__PACKAGE__ . ': parse_email() requires a string or scalar reference') if ref($text); 749: 750: # Sanitise: strip control characters that could affect terminal output. 751: # Keep \t (tabs in headers), \n (line endings), \r (CRLF mail format). 752: $text =~ s/[^\x09\x0A\x0D\x20-\x7E\x80-\xFF]//g if defined $text; 753: 754: # Store the sanitised raw text for later reproduction in reports 755: $self->{_raw} = $text // ''; 756: 757: # Invalidate all per-message lazy caches 758: $self->{_origin} = undef; 759: $self->{_urls} = undef; 760: $self->{_mailto_domains} = undef; 761: $self->{_contacts} = undef; 762: $self->{_domain_info} = {}; 763: $self->{_risk} = undef; 764: $self->{_auth_results} = undef; 765: $self->{_sending_sw} = []; 766: $self->{_rcvd_tracking} = []; 767: 768: # Perform synchronous header/body parsing (no network I/O) 769: $self->_split_message($text) if defined $text && $text =~ /\S/; 770: return $self;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_634_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_634_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );771: } 772: 773: # ----------------------------------------------------------------------- 774: # Public: originating host 775: # ----------------------------------------------------------------------- 776: 777: =head2 originating_ip() 778: 779: Identifies the IP address of the machine that originally injected the 780: message into the mail system by walking the C<Received:> chain, skipping 781: private/trusted hops, and enriching the first external hop with rDNS, 782: WHOIS/RDAP organisation name, abuse contact, and country code. 783: 784: Both IPv4 and IPv6 addresses are extracted and evaluated. 785: 786: The result is cached; subsequent calls return the same hashref without 787: repeating network I/O. 788: 789: =head3 Usage 790: 791: my $orig = $analyser->originating_ip(); 792: if (defined $orig) { 793: printf "Origin: %s (%s)\n", $orig->{ip}, $orig->{rdns}; 794: printf "Owner: %s\n", $orig->{org}; 795: } 796: 797: =head3 Arguments 798: 799: None. C<parse_email()> must have been called first. 800: 801: =head3 Returns 802: 803: A hashref with keys C<ip>, C<rdns>, C<org>, C<abuse>, C<confidence>, 804: C<note>, and C<country> (may be undef). Returns C<undef> if no suitable 805: originating IP can be determined. 806: 807: =head3 Side Effects 808: 809: On first call: one PTR lookup and one RDAP/WHOIS query. Results are cached 810: in the object and in the cross-message CHI cache (if available). 811: 812: =head3 Notes 813: 814: Only the first (oldest) external IP in the chain is reported. See 815: C<received_trail()> for the full chain. 816: 817: =head3 API Specification 818: 819: =head4 Input 820: 821: [] 822: 823: =head4 Output 824: 825: { 826: type => [ 'hashref', 'undef' ], 827: keys => { 828: ip => { type => 'string', regex => qr/[\d.:a-fA-F]/ }, 829: rdns => { type => 'string' }, 830: org => { type => 'string' }, 831: abuse => { type => 'string' }, 832: confidence => { type => 'string', memberof => [ 'high', 'medium', 'low' ] }, 833: note => { type => 'string' }, 834: country => { type => 'string', optional => 1 }, 835: }, 836: } 837: 838: =cut 839: 840: sub originating_ip { 841: my $self = $_[0]; 842: 843: # Return the cached result if we already have it 844: $self->{_origin} //= $self->_find_origin(); 845: return $self->{_origin};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_770_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_770_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );846: } 847: 848: # ----------------------------------------------------------------------- 849: # Public: HTTP/HTTPS URLs 850: # ----------------------------------------------------------------------- 851: 852: =head2 embedded_urls() 853: 854: Extracts every HTTP and HTTPS URL from the message body and enriches each 855: one with the hosting IP address, network organisation name, abuse contact, 856: and country code. Both IPv4 and IPv6 host addresses are supported. 857: 858: URL extraction runs across both plain-text and HTML body parts. DNS 859: lookups for each unique hostname are optionally parallelised via 860: C<AnyEvent::DNS> if that module is installed. 861: 862: The result is cached; subsequent calls return the same list without 863: repeating network I/O. 864: 865: =head3 Usage 866: 867: my @urls = $analyser->embedded_urls(); 868: for my $u (@urls) { 869: printf "URL: %s host: %s org: %s\n", 870: $u->{url}, $u->{host}, $u->{org}; 871: } 872: 873: =head3 Arguments 874: 875: None. C<parse_email()> must have been called first. 876: 877: =head3 Returns 878: 879: A list of hashrefs, one per unique URL, in first-seen order. Returns an 880: empty list if no HTTP/HTTPS URLs are present. Each hashref has keys 881: C<url>, C<host>, C<ip>, C<org>, C<abuse>, C<country>. 882: 883: =head3 Side Effects 884: 885: Per unique hostname: one A/AAAA lookup and one RDAP/WHOIS query. Results 886: are cached in the object and in the cross-message CHI cache. 887: 888: =head3 Notes 889: 890: Only C<http://> and C<https://> URLs are extracted. URL shortener hosts 891: are included in the returned list (they are flagged by C<risk_assessment()>). 892: 893: When L<LWP::UserAgent> is available, URLs whose host is a known URL shortener 894: or cloud-storage redirect cloaker (Google Cloud Storage C<storage.googleapis.com>, 895: Azure Blob Storage C<blob.core.windows.net>, Cloudflare Pages C<pages.dev>, 896: S3 buckets, CloudFront distributions, etc.) are automatically resolved by 897: following HTTP 3xx redirects and HTML C<meta http-equiv="refresh"> or 898: C<window.location> patterns up to C<$REDIRECT_MAX_HOPS> hops. The resolved 899: destination URL is added to the returned list alongside the original, so abuse 900: contacts for the real phishing target are always reported even when the email 901: body contains only an object-store redirect URL. 902: 903: =head3 API Specification 904: 905: =head4 Input 906: 907: [] 908: 909: =head4 Output 910: 911: ( 912: { 913: type => 'hashref', 914: keys => { 915: url => { type => 'string', regex => qr{^https?://}i }, 916: host => { type => 'string' }, 917: ip => { type => 'string' }, 918: org => { type => 'string' }, 919: abuse => { type => 'string' }, 920: country => { type => 'string', optional => 1 }, 921: }, 922: }, 923: ... 924: ) 925: 926: =cut 927: 928: sub embedded_urls { 929: my $self = $_[0]; 930: 931: $self->{_urls} //= $self->_extract_and_resolve_urls(); 932: return @{ $self->{_urls} };Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_845_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_845_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );933: } 934: 935: # ----------------------------------------------------------------------- 936: # Public: mailto / reply-to / from domains 937: # ----------------------------------------------------------------------- 938: 939: =head2 mailto_domains() 940: 941: Identifies every domain associated with the message as a contact, reply, 942: or delivery address, then runs a full intelligence pipeline on each one 943: (A record, MX, NS, WHOIS) to determine hosting and registration details. 944: 945: The result is cached; subsequent calls return the same list without 946: repeating network I/O. 947: 948: =head3 Usage 949: 950: my @domains = $analyser->mailto_domains(); 951: for my $d (@domains) { 952: printf "Domain: %s registrar: %s\n", 953: $d->{domain}, $d->{registrar} // 'unknown'; 954: } 955: 956: =head3 Arguments 957: 958: None. C<parse_email()> must have been called first. 959: 960: =head3 Returns 961: 962: A list of hashrefs, one per unique domain. See the main POD for the full 963: set of possible keys. Returns an empty list if no qualifying domains are 964: found. 965: 966: =head3 Side Effects 967: 968: Per unique domain: up to three A lookups, one MX lookup, one NS lookup, 969: and two WHOIS queries. Results are cached in the object and in the 970: cross-message CHI cache. 971: 972: =head3 Notes 973: 974: MX and NS lookups require C<Net::DNS>. Without it those keys are absent 975: from every returned hashref. 976: 977: =head3 API Specification 978: 979: =head4 Input 980: 981: [] 982: 983: =head4 Output 984: 985: ( 986: { 987: type => 'hashref', 988: keys => { 989: domain => { type => 'string' }, 990: source => { type => 'string' }, 991: # All other keys optional -- see main POD 992: }, 993: }, 994: ... 995: ) 996: 997: =cut 998: 999: sub mailto_domains { 1000: my $self = $_[0]; 1001: 1002: $self->{_mailto_domains} //= $self->_extract_and_analyse_domains(); 1003: return @{ $self->{_mailto_domains} };Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_932_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_932_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1004: } 1005: 1006: =head2 all_domains() 1007: 1008: Returns the deduplicated union of every registrable domain seen anywhere 1009: in the message -- URL hosts from C<embedded_urls()> and contact domains 1010: from C<mailto_domains()> -- normalised to eTLD+1 form. 1011: 1012: Triggers C<embedded_urls()> and C<mailto_domains()> lazily. 1013: 1014: =head3 Usage 1015: 1016: my @domains = $analyser->all_domains(); 1017: print "$_\n" for @domains; 1018: 1019: =head3 Arguments 1020: 1021: None. 1022: 1023: =head3 Returns 1024: 1025: A list of plain strings (registrable domain names), lower-cased, no 1026: duplicates, in first-seen order. 1027: 1028: =head3 Side Effects 1029: 1030: Triggers C<embedded_urls()> and C<mailto_domains()> if not already cached. 1031: 1032: =head3 Notes 1033: 1034: Normalisation to eTLD+1 uses C<Domain::PublicSuffix> if installed, falling 1035: back to a built-in heuristic otherwise. 1036: 1037: =head3 API Specification 1038: 1039: =head4 Input 1040: 1041: [] 1042: 1043: =head4 Output 1044: 1045: ( 1046: { type => 'string', regex => qr/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/ }, 1047: ... 1048: ) 1049: 1050: =cut 1051: 1052: sub all_domains { ●1053 → 1057 → 1063 1053: my $self = $_[0]; 1054: my (%seen, @out); 1055: 1056: # Collect registrable domains from URL hosts first 1057: for my $u ($self->embedded_urls()) { 1058: my $dom = _registrable($u->{host}); 1059: push @out, $dom if $dom && !$seen{$dom}++; 1060: } 1061: 1062: # Then from contact domains (normalise subdomains to registrable parent) ●1063 → 1063 → 1067 1063: for my $d ($self->mailto_domains()) { 1064: my $dom = _registrable($d->{domain}) // $d->{domain}; 1065: push @out, $dom if $dom && !$seen{$dom}++; 1066: } 1067: return @out;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1003_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1003_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1068: } 1069: 1070: =head2 unresolved_contacts() 1071: 1072: Returns a list of domains and URL hosts found in the message for which no 1073: abuse contact could be determined. Useful for surfacing parties that may 1074: warrant manual investigation. 1075: 1076: =head3 Usage 1077: 1078: my @unresolved = $analyser->unresolved_contacts(); 1079: for my $u (@unresolved) { 1080: printf "Unresolved: %s (%s) via %s\n", 1081: $u->{domain}, $u->{type}, $u->{source}; 1082: } 1083: 1084: =head3 Arguments 1085: 1086: None. 1087: 1088: =head3 Returns 1089: 1090: A list of hashrefs, each with keys C<domain>, C<type> (C<'url_host'> or 1091: C<'domain'>), and C<source> (where the domain was found). 1092: 1093: =head3 Side Effects 1094: 1095: Triggers C<embedded_urls()>, C<mailto_domains()>, C<abuse_contacts()>, 1096: and C<form_contacts()> if not already cached. 1097: 1098: =head3 Notes 1099: 1100: Domains sourced only from spoofable sending headers (C<From:>, 1101: C<Return-Path:>, C<Sender:>) are excluded. 1102: 1103: =head3 API Specification 1104: 1105: =head4 Input 1106: 1107: [] 1108: 1109: =head4 Output 1110: 1111: ( 1112: { 1113: type => 'hashref', 1114: keys => { 1115: domain => { type => 'string' }, 1116: type => { type => 'string', memberof => [ 'url_host', 'domain' ] }, 1117: source => { type => 'string' }, 1118: }, 1119: }, 1120: ... 1121: ) 1122: 1123: =cut 1124: 1125: sub unresolved_contacts { ●1126 → 1130 → 1140 1126: my $self = $_[0]; 1127: 1128: # Build a set of domains already covered by email or form contacts 1129: my %covered; 1130: for my $c ($self->abuse_contacts(), $self->form_contacts()) { 1131: my $dom = $c->{form_domain}; 1132: unless ($dom) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1067_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1067_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1133: # Extract domain from abuse email address 1134: ($dom) = ($c->{address} // '') =~ /\@([\w.-]+)/; 1135: } 1136: $covered{lc $dom}++ if $dom; 1137: } 1138: 1139: # Also mark URL hosts that already have a resolved abuse address ●1140 → 1140 → 1145 1140: for my $u ($self->embedded_urls()) { 1141: (my $bare = lc $u->{host}) =~ s/^www\.//; 1142: $covered{$bare}++ if $u->{abuse} && $u->{abuse} ne '(unknown)'; 1143: } 1144: ●1145 → 1148 → 1160 1145: my (@out, %seen); 1146: 1147: # Check URL hosts first 1148: for my $u ($self->embedded_urls()) { 1149: (my $bare = lc $u->{host}) =~ s/^www\.//; 1150: next if $covered{$bare}; 1151: next if $seen{"url:$bare"}++; 1152: push @out, { 1153: domain => $u->{host}, 1154: type => 'url_host', 1155: source => 'URL in body', 1156: }; 1157: } 1158: 1159: # Then check contact domains, skipping spoofable-header-only sources ●1160 → 1160 → 1173 1160: for my $d ($self->mailto_domains()) { 1161: my $dom = $d->{domain}; 1162: my $source = $d->{source} // ''; 1163: next if $source =~ /^(?:From:|Return-Path:|Sender:) header$/; 1164: next if $covered{lc $dom}; 1165: next if $seen{"dom:$dom"}++; 1166: push @out, { 1167: domain => $dom, 1168: type => 'domain', 1169: source => $source, 1170: }; 1171: } 1172: 1173: return @out;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1132_3: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes1174: } 1175: 1176: # ----------------------------------------------------------------------- 1177: # Public: sending software fingerprint 1178: # ----------------------------------------------------------------------- 1179: 1180: =head2 sending_software() 1181: 1182: Returns information extracted from headers that identify the software or 1183: server-side infrastructure used to compose or inject the message. Headers 1184: such as C<X-PHP-Originating-Script> reveal the exact PHP script and Unix 1185: account responsible on shared-hosting platforms. 1186: 1187: Data is extracted during C<parse_email()> with no network I/O. 1188: 1189: =head3 Usage 1190: 1191: my @sw = $analyser->sending_software(); 1192: for my $s (@sw) { 1193: printf "%-30s : %s\n", $s->{header}, $s->{value}; 1194: } 1195: 1196: =head3 Arguments 1197: 1198: None. C<parse_email()> must have been called first. 1199: 1200: =head3 Returns 1201: 1202: A list of hashrefs in alphabetical header-name order. Returns an empty 1203: list if none of the watched headers are present. Each hashref has keys 1204: C<header>, C<value>, and C<note>. 1205: 1206: =head3 Side Effects 1207: 1208: None. Data is pre-collected during C<parse_email()>. 1209: 1210: =head3 Notes 1211: 1212: Header names are lower-cased. Header values are stored verbatim. 1213: 1214: =head3 API Specification 1215: 1216: =head4 Input 1217: 1218: [] 1219: 1220: =head4 Output 1221: 1222: ( 1223: { 1224: type => 'hashref', 1225: keys => { 1226: header => { type => 'string' }, 1227: value => { type => 'string' }, 1228: note => { type => 'string' }, 1229: }, 1230: }, 1231: ... 1232: ) 1233: 1234: =cut 1235: 1236: sub sending_software { 1237: my $self = $_[0]; 1238: 1239: return @{ $self->{_sending_sw} };Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1173_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1173_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1240: } 1241: 1242: # ----------------------------------------------------------------------- 1243: # Public: per-hop tracking IDs 1244: # ----------------------------------------------------------------------- 1245: 1246: =head2 received_trail() 1247: 1248: Returns per-hop tracking data extracted from the C<Received:> header chain: 1249: the IP address, envelope recipient address, and server session ID for each 1250: relay. ISP postmasters use these identifiers to locate the SMTP session in 1251: their logs. 1252: 1253: =head3 Usage 1254: 1255: my @trail = $analyser->received_trail(); 1256: for my $hop (@trail) { 1257: printf "IP: %s ID: %s\n", 1258: $hop->{ip} // '?', $hop->{id} // '?'; 1259: } 1260: 1261: =head3 Arguments 1262: 1263: None. C<parse_email()> must have been called first. 1264: 1265: =head3 Returns 1266: 1267: A list of hashrefs in oldest-first order. Returns an empty list if no 1268: C<Received:> headers are present or none yielded extractable data. Each 1269: hashref has keys C<received>, C<ip> (may be undef), C<for> (may be undef), 1270: C<id> (may be undef). 1271: 1272: =head3 Side Effects 1273: 1274: None. Data is pre-collected during C<parse_email()>. 1275: 1276: =head3 Notes 1277: 1278: Private IPs are NOT filtered here; all IPs including RFC 1918 addresses 1279: are returned as found. Filtering is applied only by C<originating_ip()>. 1280: 1281: =head3 API Specification 1282: 1283: =head4 Input 1284: 1285: [] 1286: 1287: =head4 Output 1288: 1289: ( 1290: { 1291: type => 'hashref', 1292: keys => { 1293: received => { type => 'string' }, 1294: ip => { type => 'string', optional => 1 }, 1295: for => { type => 'string', optional => 1 }, 1296: id => { type => 'string', optional => 1 }, 1297: }, 1298: }, 1299: ... 1300: ) 1301: 1302: =cut 1303: 1304: sub received_trail { 1305: my $self = $_[0]; 1306: 1307: return @{ $self->{_rcvd_tracking} };Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1239_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1239_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1308: } 1309: 1310: # ----------------------------------------------------------------------- 1311: # Public: risk assessment 1312: # ----------------------------------------------------------------------- 1313: 1314: =head2 risk_assessment() 1315: 1316: Evaluates the message against heuristic checks and returns an overall risk 1317: level, a weighted numeric score, and a list of every specific red flag. 1318: 1319: The assessment covers five categories: originating IP, email authentication, 1320: Date: header validity, identity/header consistency, and URL/domain properties. 1321: 1322: The result is cached; subsequent calls return the same hashref without 1323: repeating any analysis. 1324: 1325: =head3 Usage 1326: 1327: my $risk = $analyser->risk_assessment(); 1328: printf "Risk: %s (score: %d)\n", $risk->{level}, $risk->{score}; 1329: for my $f (@{ $risk->{flags} }) { 1330: printf " [%s] %s\n", $f->{severity}, $f->{detail}; 1331: } 1332: 1333: =head3 Arguments 1334: 1335: None. C<parse_email()> must have been called first. 1336: 1337: =head3 Returns 1338: 1339: A hashref with keys C<level> (HIGH/MEDIUM/LOW/INFO), C<score> (integer), 1340: and C<flags> (arrayref of hashrefs with C<severity>, C<flag>, C<detail>). 1341: 1342: =head3 Side Effects 1343: 1344: Triggers C<originating_ip()>, C<embedded_urls()>, and C<mailto_domains()> 1345: if not already cached. 1346: 1347: =head3 Notes 1348: 1349: Scores: HIGH >= 9, MEDIUM >= 5, LOW >= 2, INFO < 2. 1350: Flag weights: HIGH=3, MEDIUM=2, LOW=1, INFO=0. 1351: 1352: =head3 API Specification 1353: 1354: =head4 Input 1355: 1356: [] 1357: 1358: =head4 Output 1359: 1360: { 1361: type => 'hashref', 1362: keys => { 1363: level => { type => 'string', memberof => ['HIGH', 'MEDIUM', 'LOW', 'INFO'] }, 1364: score => { type => 'integer' }, 1365: flags => { type => 'arrayref' }, 1366: }, 1367: } 1368: 1369: =cut 1370: 1371: sub risk_assessment { 1372: my $self = $_[0]; 1373: 1374: return $self->{_risk} if $self->{_risk};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1307_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1307_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1375: 1376: my (@flags, $score); 1377: $score = 0; 1378: 1379: # Closure to record a flag and accumulate its weight 1380: my $flag = sub { 1381: my ($severity, $name, $detail) = @_; 1382: $score += $FLAG_WEIGHT{$severity} // 1; 1383: push @flags, { severity => $severity, flag => $name, detail => $detail }; 1384: }; 1385: 1386: $self->_risk_check_origin($flag); 1387: $self->_risk_check_auth($flag); 1388: $self->_risk_check_date($flag); 1389: $self->_risk_check_identity($flag); 1390: $self->_risk_check_urls_and_domains($flag); 1391: 1392: # Determine overall risk level from accumulated score 1393: my $level = $score >= $SCORE_HIGH ? 'HIGH'Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1374_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1374_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1394: : $score >= $SCORE_MEDIUM ? 'MEDIUM'Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1393_21_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1393_21_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1393_21_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1395: : $score >= $SCORE_LOW ? 'LOW'Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1394_21_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1394_21_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1394_21_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1396: : 'INFO'; 1397: 1398: $self->{_risk} = { level => $level, score => $score, flags => \@flags }; 1399: return $self->{_risk};Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1395_21_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1395_21_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1395_21_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1400: } 1401: 1402: # _risk_check_origin( $flag ) 1403: # 1404: # Purpose: 1405: # Evaluate the originating IP for residential rDNS, absent rDNS, 1406: # low-confidence origin, and high-spam-volume country. 1407: # 1408: # Entry criteria: 1409: # $flag -- coderef( severity, name, detail ) that accumulates flags. 1410: # 1411: # Exit status: 1412: # Returns nothing; side effects via $flag closure. 1413: 1414: sub _risk_check_origin :Private { ●1415 → 1422 → 1433 1415: my ($self, $flag) = @_; 1416: my $orig = $self->originating_ip(); 1417: return unless $orig; 1418: 1419: return if(!defined($orig->{ip})); 1420: 1421: # Residential / broadband rDNS patterns suggest a compromised host 1422: if ($orig->{rdns} && $orig->{rdns} =~ /Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1399_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1399_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1423: \d+[-_.]\d+[-_.]\d+[-_.]\d+ # dotted-quad in rDNS 1424: | (?:dsl|adsl|cable|broad|dial|dynamic|dhcp|ppp| 1425: residential|cust|home|pool|client|user| 1426: static\d|host\d) 1427: /xi) { 1428: $flag->('HIGH', 'residential_sending_ip', 1429: "Sending IP $orig->{ip} rDNS '$orig->{rdns}' looks like a broadband/residential line, not a legitimate mail server"); 1430: } 1431: 1432: # Absence of rDNS is a strong spam indicator ●1433 → 1433 → 1439 1433: if (!$orig->{rdns} || $orig->{rdns} eq '(no reverse DNS)') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1422_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1434: $flag->('HIGH', 'no_reverse_dns', 1435: "Sending IP $orig->{ip} has no reverse DNS -- legitimate mail servers always have rDNS"); 1436: } 1437: 1438: # Low-confidence origin means the IP came from an unverifiable header ●1439 → 1439 → 1445 1439: if ($orig->{confidence} eq 'low') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1433_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1440: $flag->('MEDIUM', 'low_confidence_origin', 1441: "Originating IP taken from unverified header ($orig->{note})"); 1442: } 1443: 1444: # Statistically high-volume spam countries (informational only) ●1445 → 1445 → 0 1445: if ($orig->{country} && $orig->{country} =~ /^(?:CN|RU|NG|VN|IN|PK|BD)$/) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1439_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1446: $flag->('INFO', 'high_spam_country', 1447: 'Sending IP is in ' . _country_name($orig->{country}) . 1448: " ($orig->{country}) -- statistically high spam volume country"); 1449: } 1450: } 1451: 1452: # _risk_check_auth( $flag ) 1453: # 1454: # Purpose: 1455: # Evaluate SPF, DKIM, DMARC results and DKIM signing domain alignment. 1456: # 1457: # Entry criteria: 1458: # $flag -- accumulator coderef. 1459: # 1460: # Exit status: 1461: # Returns nothing; side effects via $flag closure. 1462: 1463: sub _risk_check_auth :Private { ●1464 → 1467 → 1479 1464: my ($self, $flag) = @_; 1465: my $auth = $self->_parse_auth_results_cached(); 1466: 1467: if (defined $auth->{spf}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1445_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1468: if ($auth->{spf} =~ /^fail/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1467_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1469: $flag->('HIGH', 'spf_fail', 1470: "SPF result: $auth->{spf} -- sending IP not authorised by domain's SPF record"); 1471: } elsif ($auth->{spf} =~ /^softfail/i) { 1472: $flag->('MEDIUM', 'spf_softfail', 1473: "SPF result: softfail (~all) -- sending IP not explicitly authorised"); 1474: } elsif ($auth->{spf} !~ /^pass/i) { 1475: $flag->('HIGH', 'spf_fail', 1476: "SPF result: $auth->{spf} -- sending IP not authorised"); 1477: } 1478: } ●1479 → 1479 → 1483 1479: if (defined $auth->{dkim} && $auth->{dkim} !~ /^pass/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1468_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1480: $flag->('HIGH', 'dkim_fail', 1481: "DKIM result: $auth->{dkim} -- message signature invalid or absent"); 1482: } ●1483 → 1483 → 1488 1483: if (defined $auth->{dmarc} && $auth->{dmarc} !~ /^pass/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1479_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1484: $flag->('HIGH', 'dmarc_fail', "DMARC result: $auth->{dmarc}"); 1485: } 1486: 1487: # DKIM signing domain vs From: domain mismatch check ●1488 → 1496 → 0 1488: return unless $auth->{dkim_domain}; 1489: my ($from_domain) = ($self->_header_value('from') // '') =~ /\@([\w.-]+)/; 1490: return unless $from_domain; 1491: my $reg_dkim = _registrable($auth->{dkim_domain}) // $auth->{dkim_domain}; 1492: my $reg_from = _registrable(lc $from_domain) // lc $from_domain; 1493: return if $reg_dkim eq $reg_from; 1494: 1495: # Passing DKIM with a different domain is normal for ESPs 1496: if ($auth->{dkim} && $auth->{dkim} =~ /^pass/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1483_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1497: $flag->('INFO', 'dkim_domain_mismatch', 1498: "DKIM signed by '$auth->{dkim_domain}' but From: domain is '$from_domain'" 1499: . ' -- message sent via third-party sender (normal for bulk/ESP mail)'); 1500: } else { 1501: # Failing DKIM plus mismatched domain is more suspicious 1502: $flag->('MEDIUM', 'dkim_domain_mismatch', 1503: "DKIM signed by '$auth->{dkim_domain}' but From: domain is '$from_domain'" 1504: . ' and DKIM did not pass -- possible impersonation'); 1505: } 1506: } 1507: 1508: # _risk_check_date( $flag ) 1509: # 1510: # Purpose: 1511: # Validate the Date: header for presence, plausible timezone, and 1512: # date not too far in the past or future. 1513: # 1514: # Entry criteria: 1515: # $flag -- accumulator coderef. 1516: # 1517: # Exit status: 1518: # Returns nothing; side effects via $flag closure. 1519: 1520: sub _risk_check_date :Private { ●1521 → 1524 → 1531 1521: my ($self, $flag) = @_; 1522: my $date_raw = $self->_header_value('date'); 1523: 1524: if (!$date_raw || $date_raw !~ /\S/) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1496_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1525: $flag->('MEDIUM', 'missing_date', 1526: 'No Date: header -- violates RFC 5322; common in spam'); 1527: return; 1528: } 1529: 1530: # Check for an implausible timezone offset (outside real-world bounds) ●1531 → 1531 → 1545 1531: if ($date_raw =~ /([+-])(\d{2})(\d{2})\s*$/) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1524_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1532: my ($sign, $hh, $mm) = ($1, $2, $3); 1533: my $offset_mins = $hh * 60 + $mm; 1534: my $implausible = $mm >= 60Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1531_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1535: || ($sign eq '+' && $offset_mins > $TZ_MAX_POS_MINS)Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1534_25_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1534_25_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1534_25_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1536: || ($sign eq '-' && $offset_mins > $TZ_MAX_NEG_MINS);Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1535_37_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1535_37_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1535_37_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1537: if ($implausible) {Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1536_37_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1536_37_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1536_37_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1538: $flag->('MEDIUM', 'implausible_timezone', 1539: "Date: '$date_raw' contains an implausible timezone offset " 1540: . "($sign$hh$mm) -- header is likely forged"); 1541: } 1542: } 1543: 1544: # Check for dates more than DATE_SKEW_DAYS outside the analysis window ●1545 → 1548 → 0 1545: my $date_epoch = _parse_rfc2822_date($date_raw); 1546: return unless defined $date_epoch; 1547: my $delta = time() - $date_epoch; 1548: if ($delta > $DATE_SKEW_DAYS * $SECS_PER_DAY) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1537_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1549: $flag->('LOW', 'suspicious_date', 1550: "Date: '$date_raw' is more than $DATE_SKEW_DAYS days in the past"); 1551: } elsif ($delta < -($DATE_SKEW_DAYS * $SECS_PER_DAY)) {Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_1548_13_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1548_13_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1548_13_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_1548_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1552: $flag->('LOW', 'suspicious_date', 1553: "Date: '$date_raw' is more than $DATE_SKEW_DAYS days in the future"); 1554: } 1555: } 1556: 1557: # _risk_check_identity( $flag ) 1558: # 1559: # Purpose: 1560: # Check From: display-name spoofing, free webmail, Reply-To mismatch, 1561: # undisclosed recipients, and MIME-encoded Subject. 1562: # 1563: # Entry criteria: 1564: # $flag -- accumulator coderef. 1565: # 1566: # Exit status: 1567: # Returns nothing; side effects via $flag closure. 1568: 1569: sub _risk_check_identity :Private { ●1570 → 1575 → 1591 1570: my ($self, $flag) = @_; 1571: my $from_raw = $self->_header_value('from') // ''; 1572: my $from_decoded = $self->_decode_mime_words($from_raw); 1573: 1574: # Display-name domain spoofing: "PayPal paypal.com" <phish@evil.example> 1575: if ($from_decoded =~ /^"?([^"<]+?)"?\s*<([^>]+)>/) {Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1551_18_>: Numeric boundary flip < to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1551_18_<=: Numeric boundary flip < to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1551_18_>=: Numeric boundary flip < to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1576: my ($display, $addr) = ($1, $2); 1577: while ($display =~ /\b([\w-]+\.(?:com|net|org|io|co|uk|au|gov|edu))\b/gi) { 1578: my $disp_domain = lc $1; 1579: my ($addr_domain) = $addr =~ /\@([\w.-]+)/; 1580: $addr_domain = lc($addr_domain // ''); 1581: my $reg_disp = _registrable($disp_domain); 1582: my $reg_addr = _registrable($addr_domain); 1583: if ($reg_disp && $reg_addr && $reg_disp ne $reg_addr) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1575_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1584: $flag->('HIGH', 'display_name_domain_spoof', 1585: "From: display name mentions '$disp_domain' but actual address is <$addr>"); 1586: } 1587: } 1588: } 1589: 1590: # Free webmail sender flag (no corporate infrastructure) ●1591 → 1591 → 1598 1591: if ($from_raw =~ /\@(gmail|yahoo|hotmail|outlook|live|aol|protonmail|yandex)\./iMutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1583_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1592: || $from_raw =~ /\@mail\.ru(?:[\s>]|$)/i) { 1593: $flag->('MEDIUM', 'free_webmail_sender', 1594: "Message sent from free webmail address ($from_raw)"); 1595: } 1596: 1597: # Reply-To differs from From: -- replies harvested by different address ●1598 → 1599 → 1609 1598: my $reply_to = $self->_header_value('reply-to'); 1599: if ($reply_to) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1591_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1600: my ($from_addr) = $from_raw =~ /([\w.+%-]+\@[\w.-]+)/; 1601: my ($reply_addr) = $reply_to =~ /([\w.+%-]+\@[\w.-]+)/; 1602: if ($from_addr && $reply_addr && lc($from_addr) ne lc($reply_addr)) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1599_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1603: $flag->('MEDIUM', 'reply_to_differs_from_from', 1604: "Reply-To ($reply_addr) differs from From: ($from_addr)"); 1605: } 1606: } 1607: 1608: # Undisclosed or absent To: header ●1609 → 1610 → 1616 1609: my $to = $self->_header_value('to') // ''; 1610: if ($to =~ /undisclosed|:;/ || $to eq '') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1602_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1611: $flag->('MEDIUM', 'undisclosed_recipients', 1612: "To: header is '$to' -- message was bulk-sent with hidden recipient list"); 1613: } 1614: 1615: # MIME-encoded Subject (potential filter evasion) ●1616 → 1617 → 0 1616: my $subj_raw = $self->_header_value('subject') // ''; 1617: if ($subj_raw =~ /=\?[^?]+\?[BQ]\?/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1610_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1618: $flag->('LOW', 'encoded_subject', 1619: "Subject line is MIME-encoded: '$subj_raw' (decoded: '" 1620: . $self->_decode_mime_words($subj_raw) . "')"); 1621: } 1622: } 1623: 1624: # _risk_check_urls_and_domains( $flag ) 1625: # 1626: # Purpose: 1627: # Check embedded URLs for shorteners and plain HTTP, and contact domains 1628: # for recent registration, imminent expiry, and lookalike brand names. 1629: # 1630: # Entry criteria: 1631: # $flag -- accumulator coderef. 1632: # 1633: # Exit status: 1634: # Returns nothing; side effects via $flag closure. 1635: 1636: sub _risk_check_urls_and_domains :Private { ●1637 → 1640 → 1667 1637: my ($self, $flag) = @_; 1638: my (%shortener_seen, %cloaker_seen, %url_host_seen); 1639: 1640: for my $u ($self->embedded_urls()) { 1641: # Skip trusted infrastructure -- these are not spam indicators 1642: next unless($u->{host}); 1643: my $bare = lc $u->{host}; 1644: next unless defined($bare); 1645: $bare =~ s/^www\.//; 1646: next if $self->{trusted_domains}->{$bare}; 1647: next if $TRUSTED_DOMAINS{$bare}; 1648: 1649: # URL shortener hides real destination 1650: if(($URL_SHORTENERS{$bare} || $self->{url_shorteners}->{$bare}) && !$shortener_seen{$bare}++) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1617_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1651: $flag->('MEDIUM', 'url_shortener', 1652: "$u->{host} is a URL shortener -- the real destination is hidden"); 1653: } 1654: # Cloud object-store / CDN used as a redirect cloaker 1655: if ($self->_is_redirect_cloaker($bare) && !$cloaker_seen{$bare}++) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1650_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1656: $flag->('MEDIUM', 'redirect_cloaker', 1657: "$u->{host} is a cloud storage or CDN host used as a redirect cloaker -- the real phishing destination is hidden behind a client-side redirect"); 1658: } 1659: # Plain HTTP provides no encryption 1660: if ($u->{url} =~ m{^http://}i && !$url_host_seen{ $u->{host} }++) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1655_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1661: $flag->('LOW', 'http_not_https', 1662: "$u->{host} linked over plain HTTP -- no encryption"); 1663: } 1664: } 1665: 1666: # Domain-level checks against contact/reply domains ●1667 → 1667 → 0 1667: for my $d ($self->mailto_domains()) { 1668: # Recently registered domain is a common phishing indicator 1669: if ($d->{recently_registered}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1660_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1670: $flag->('HIGH', 'recently_registered_domain', 1671: "$d->{domain} was registered $d->{registered} (less than ${\$RECENT_REG_DAYS} days ago)"); 1672: } 1673: 1674: # Domain expiry checks 1675: if ($d->{expires}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1669_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1676: if(my $exp = $self->_parse_date_to_epoch($d->{expires})) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1675_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1677: my $now = time(); 1678: my $remaining = $exp - $now; 1679: if ($remaining > 0 && $remaining < $EXPIRY_WARN_DAYS * $SECS_PER_DAY) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1676_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1680: $flag->('HIGH', 'domain_expires_soon', 1681: "$d->{domain} expires $d->{expires} -- may be a throwaway domain"); 1682: } elsif ($remaining <= 0) {Mutants (Total: 7, Killed: 0, Survived: 7)
- NUM_BOUNDARY_1679_38_>: Numeric boundary flip < to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1679_38_<=: Numeric boundary flip < to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1679_38_>=: Numeric boundary flip < to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1679_20_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1679_20_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1679_20_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_1679_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1683: $flag->('HIGH', 'domain_expired', 1684: "$d->{domain} expired $d->{expires} -- domain has lapsed"); 1685: } 1686: } 1687: } 1688: 1689: # Lookalike domain check (brand name in a non-brand domain) 1690: for my $brand (@LOOKALIKE_BRANDS) { 1691: next if(!defined($d->{domain})); 1692: if ($d->{domain} =~ /\Q$brand\E/i &&Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1682_25_<: Numeric boundary flip <= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1682_25_>: Numeric boundary flip <= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1682_25_>=: Numeric boundary flip <= to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1693: $d->{domain} !~ /^\Q$brand\E\.(?:com|co\.uk|net|org)$/) { 1694: $flag->('HIGH', 'lookalike_domain', 1695: "$d->{domain} contains brand name '$brand' but is not the real domain -- possible phishing"); 1696: last; 1697: } 1698: } 1699: } 1700: } 1701: 1702: # ----------------------------------------------------------------------- 1703: # Public: abuse report text 1704: # ----------------------------------------------------------------------- 1705: 1706: =head2 abuse_report_text() 1707: 1708: Produces a compact, plain-text string suitable for sending as the body of 1709: an abuse report email. It summarises risk level, red flags, originating IP, 1710: abuse contacts, and original message headers. The message body is omitted 1711: to keep the report concise. 1712: 1713: Use C<abuse_contacts()> to get the recipient addresses and this method for 1714: the body text. 1715: 1716: =head3 Usage 1717: 1718: my $text = $analyser->abuse_report_text(); 1719: my @contacts = $analyser->abuse_contacts(); 1720: for my $c (@contacts) { 1721: send_email(to => $c->{address}, body => $text); 1722: } 1723: 1724: =head3 Arguments 1725: 1726: None. C<parse_email()> must have been called first. 1727: 1728: =head3 Returns 1729: 1730: A plain scalar string, newline-terminated, Unix line endings. Never empty 1731: or undef. 1732: 1733: =head3 Side Effects 1734: 1735: Calls C<risk_assessment()>, C<originating_ip()>, and C<abuse_contacts()> 1736: if not already cached. 1737: 1738: =head3 Notes 1739: 1740: Output text is sanitised: control characters that could affect terminal or 1741: HTML rendering are stripped from all user-derived content before inclusion. 1742: 1743: =head3 API Specification 1744: 1745: =head4 Input 1746: 1747: [] 1748: 1749: =head4 Output 1750: 1751: { type => 'string' } 1752: 1753: =cut 1754: 1755: sub abuse_report_text { ●1756 → 1768 → 1777 1756: my $self = $_[0]; 1757: my @out; 1758: 1759: push @out, 'This is an automated abuse report generated by Email::Abuse::Investigator.', 1760: 'Please investigate the following spam/phishing message.', 1761: ''; 1762: 1763: my $risk = $self->risk_assessment(); 1764: push @out, "RISK LEVEL: $risk->{level} (score: $risk->{score})", 1765: ''; 1766: 1767: # List each red flag with its severity prefix 1768: if (@{ $risk->{flags} }) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1692_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1769: push @out, 'RED FLAGS IDENTIFIED:'; 1770: for my $f (@{ $risk->{flags} }) { 1771: push @out, " [$f->{severity}] " . _sanitise_output($f->{detail}); 1772: } 1773: push @out, ''; 1774: } 1775: 1776: # Originating IP summary block ●1777 → 1778 → 1786 1777: my $orig = $self->originating_ip(); 1778: if ($orig) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1768_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1779: push @out, 'ORIGINATING IP: ' . _sanitise_output("$orig->{ip} ($orig->{rdns})"), 1780: 'NETWORK OWNER: ' . _sanitise_output($orig->{org}), 1781: ''; 1782: } 1783: 1784: # List every reported URL so the receiving abuse desk knows exactly 1785: # which resources to investigate or suspend. ●1786 → 1787 → 1801 1786: my @urls = $self->embedded_urls(); 1787: if (@urls) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1778_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1788: push @out, 'REPORTED URLs:'; 1789: for my $u (@urls) { 1790: push @out, ' ' . _sanitise_output($u->{url}); 1791: my @meta; 1792: push @meta, "host: $u->{host}" if $u->{host}; 1793: push @meta, "IP: $u->{ip}" if $u->{ip} && $u->{ip} ne '(unresolved)'; 1794: push @meta, "org: $u->{org}" if $u->{org} && $u->{org} ne '(unknown)'; 1795: push @out, ' (' . join(' ', @meta) . ')' if @meta; 1796: } 1797: push @out, ''; 1798: } 1799: 1800: # Email abuse contacts ●1801 → 1802 → 1809 1801: my @contacts = $self->abuse_contacts(); 1802: if (@contacts) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1787_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1803: push @out, 'ABUSE CONTACTS:'; 1804: push @out, ' ' . _sanitise_output("$_->{address} ($_->{role})") for @contacts; 1805: push @out, ''; 1806: } 1807: 1808: # Web-form contacts (providers that reject email) ●1809 → 1809 → 1823 1809: if(my @form_cs = $self->form_contacts()) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1802_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1810: push @out, 'WEB-FORM REPORTS REQUIRED:', 1811: ' The following parties do not accept email -- submit manually:'; 1812: for my $c (@form_cs) { 1813: push @out, " [$c->{role}]", 1814: ' Form : ' . _sanitise_output($c->{form}); 1815: push @out, ' Domain : ' . _sanitise_output($c->{form_domain}) if $c->{form_domain}; 1816: push @out, ' Paste : ' . _sanitise_output($c->{form_paste}) if $c->{form_paste}; 1817: push @out, ' Upload : ' . _sanitise_output($c->{form_upload}) if $c->{form_upload}; 1818: } 1819: push @out, ''; 1820: } 1821: 1822: # Separator and raw headers (body excluded for brevity) ●1823 → 1827 → 1830 1823: push @out, '-' x 72, 1824: 'ORIGINAL MESSAGE HEADERS:', 1825: '-' x 72; 1826: 1827: for my $h (@{ $self->{_headers} }) { 1828: push @out, _sanitise_output("$h->{name}: $h->{value}"); 1829: } 1830: push @out, ''; 1831: 1832: return join("\n", @out);Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1809_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1833: } 1834: 1835: # ----------------------------------------------------------------------- 1836: # Public: abuse contacts 1837: # ----------------------------------------------------------------------- 1838: 1839: =head2 abuse_contacts() 1840: 1841: Collates the complete set of parties that should receive an abuse report: 1842: the sending ISP, URL host operators, contact domain web/mail/DNS/registrar 1843: contacts, account providers identified from key headers, the DKIM signer, 1844: and the ESP identified via List-Unsubscribe. 1845: 1846: Addresses are deduplicated globally; if the same address is found via 1847: multiple routes, a single entry is kept and role strings are merged. 1848: 1849: =head3 Usage 1850: 1851: my @contacts = $analyser->abuse_contacts(); 1852: my @addrs = map { $_->{address} } @contacts; 1853: 1854: =head3 Arguments 1855: 1856: None. C<parse_email()> must have been called first. 1857: 1858: =head3 Returns 1859: 1860: A list of hashrefs, one per unique abuse address, in discovery order. 1861: Each hashref has keys C<role>, C<roles> (arrayref), C<address>, C<note>, 1862: C<via>. Returns an empty list if no contacts can be determined. 1863: 1864: =head3 Side Effects 1865: 1866: Triggers C<originating_ip()>, C<embedded_urls()>, and C<mailto_domains()> 1867: if not already cached. 1868: 1869: =head3 Notes 1870: 1871: The result is cached on the object; subsequent calls return the same list 1872: without repeating the contact-collection logic. The underlying per-IP and 1873: per-domain lookups are themselves cached by C<originating_ip()>, 1874: C<embedded_urls()>, and C<mailto_domains()>. 1875: 1876: =head3 API Specification 1877: 1878: =head4 Input 1879: 1880: [] 1881: 1882: =head4 Output 1883: 1884: ( 1885: { 1886: type => 'hashref', 1887: keys => { 1888: role => { type => 'string' }, 1889: roles => { type => 'arrayref' }, 1890: address => { type => 'string', regex => qr/\@/ }, 1891: note => { type => 'string' }, 1892: via => { type => 'string', memberof => [ 'provider-table', 'ip-whois', 'domain-whois' ] } 1893: }, 1894: }, 1895: ... 1896: ) 1897: 1898: =cut 1899: 1900: sub abuse_contacts { 1901: my $self = $_[0]; 1902: $self->{_contacts} //= [ $self->_compute_abuse_contacts() ]; 1903: return @{ $self->{_contacts} };Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1832_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1832_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1904: } 1905: 1906: # _compute_abuse_contacts() -> list of contact hashrefs 1907: # 1908: # Purpose: 1909: # Actual implementation of abuse_contacts(). Separated so the public 1910: # method can cache without duplicating logic. 1911: # 1912: # Entry criteria: 1913: # parse_email() must have been called. 1914: # 1915: # Exit status: 1916: # Returns list of deduplicated contact hashrefs. 1917: 1918: sub _compute_abuse_contacts :Private { ●1919 → 1990 → 2011 1919: my $self = $_[0]; 1920: 1921: my (@contacts, %seen_idx); 1922: 1923: # Inner closure: add one contact entry, merging roles for duplicate addresses 1924: my $add = sub { 1925: my (%args) = @_; 1926: my $addr = lc($args{address} // ''); 1927: return unless $addr && $addr =~ /\@/; 1928: 1929: # Suppress addresses belonging to form-only providers (no email accepted) 1930: if ($addr =~ /\@([\w.-]+)$/) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_1903_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_1903_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );1931: my $dom = $1; 1932: my $pa = $self->_provider_abuse_for_host($dom); 1933: return if $pa && $pa->{form} && !$pa->{email}; 1934: } 1935: 1936: if (exists $seen_idx{$addr}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1930_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1937: # Merge the new role into the existing entry 1938: my $entry = $contacts[ $seen_idx{$addr} ]; 1939: push @{ $entry->{roles} }, $args{role}; 1940: 1941: # Collapse repeated role labels to avoid unreadable strings 1942: my (%role_counts, @ordered_roles); 1943: for my $r (@{ $entry->{roles} }) { 1944: push @ordered_roles, $r unless $role_counts{$r}++; 1945: } 1946: my @display = map { 1947: $role_counts{$_} > 1 ? "$_ (x$role_counts{$_})" : $_Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1936_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1948: } @ordered_roles; 1949: my $joined = join(' and ', @display); 1950: 1951: # Summarise if the merged string is too long to read. 1952: # "URL host: hostname" entries are grouped by listing the actual 1953: # hostnames so the summary is actionable (e.g. "URL host: a.example, 1954: # b.example" rather than the unhelpful "URL host, URL host"). 1955: # All other role types fall back to stripping at the first [:(\d] 1956: # boundary to produce a compact type label. 1957: if (length($joined) > $ROLE_MAX_LEN) {Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1947_22_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1947_22_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1947_22_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1958: my (@url_hosts, %seen_short, @short); 1959: for my $r (@display) { 1960: if ($r =~ /^URL host:\s*(.+)$/) {Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_1957_24_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1957_24_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1957_24_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_1957_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1961: push @url_hosts, $1; 1962: } else { 1963: (my $s = $r) =~ s/[:(\d].*//; 1964: $s =~ s/\s+$//; 1965: push @short, $s unless $seen_short{$s}++; 1966: } 1967: } 1968: my @parts; 1969: if (@url_hosts) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1960_6: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1970: my $extra = @url_hosts > 3Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1969_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1971: ? ' and ' . (@url_hosts - 3) . ' more' : ''; 1972: my @shown = @url_hosts > 3 ? @url_hosts[0..2] : @url_hosts;Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1970_29_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1970_29_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1970_29_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1973: push @parts, 'URL host: ' . join(', ', @shown) . $extra; 1974: } 1975: push @parts, @short; 1976: $joined = scalar(@display) . ' routes: ' . join(', ', @parts); 1977: } 1978: $entry->{role} = $joined; 1979: return; 1980: } 1981: 1982: # First time seeing this address -- record and store 1983: $seen_idx{$addr} = scalar @contacts; 1984: $args{roles} = [ $args{role} ]; 1985: push @contacts, \%args; 1986: }; 1987: 1988: # Route 1 -- Sending ISP (originating IP) 1989: my $orig = $self->originating_ip(); 1990: if ($orig) {Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_1972_29_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1972_29_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_1972_29_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );1991: my $pa = $self->_provider_abuse_for_ip($orig->{ip}, $orig->{rdns}); 1992: if ($pa) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1990_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes1993: $add->( 1994: role => 'Sending ISP', 1995: address => $pa->{email}, 1996: note => "$orig->{ip} ($orig->{rdns}) -- $pa->{note}", 1997: via => 'provider-table', 1998: ); 1999: } 2000: if ($orig->{abuse} && $orig->{abuse} ne '(unknown)') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_1992_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2001: $add->( 2002: role => 'Sending ISP', 2003: address => $orig->{abuse}, 2004: note => "Network owner of originating IP $orig->{ip} ($orig->{org})", 2005: via => 'ip-whois', 2006: ); 2007: } 2008: } 2009: 2010: # Route 2 -- URL hosts ●2011 → 2012 → 2039 2011: my %url_host_seen; 2012: for my $u ($self->embedded_urls()) { 2013: next if $url_host_seen{ $u->{host} }++; 2014: my $bare_host = lc $u->{host}; 2015: $bare_host =~ s/^www\.//; 2016: # Skip trusted infrastructure (Google, W3C, etc.) 2017: next if $self->{trusted_domains}->{$bare_host}; 2018: next if $TRUSTED_DOMAINS{$bare_host}; 2019: my $pa = $self->_provider_abuse_for_host($u->{host}); 2020: if ($pa) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2000_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2021: $add->( 2022: role => "URL host: $u->{host}", 2023: address => $pa->{email}, 2024: note => "$u->{host} -- $pa->{note}", 2025: via => 'provider-table', 2026: ); 2027: } 2028: if ($u->{abuse} && $u->{abuse} ne '(unknown)') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2020_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2029: $add->( 2030: role => "URL host: $u->{host}", 2031: address => $u->{abuse}, 2032: note => "Hosting $u->{host} ($u->{ip}, $u->{org})", 2033: via => 'ip-whois', 2034: ); 2035: } 2036: } 2037: 2038: # Route 3 -- Contact domain hosting and registration ●2039 → 2039 → 2106 2039: for my $d ($self->mailto_domains()) { 2040: my $dom = $d->{domain}; 2041: 2042: # Web host contact 2043: if ($d->{web_abuse}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2028_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2044: my $pa = $self->_provider_abuse_for_host($dom); 2045: if ($pa) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2043_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2046: $add->(role => "Web host of $dom", address => $pa->{email}, 2047: note => $pa->{note}, via => 'provider-table'); 2048: } 2049: $add->( 2050: role => "Web host of $dom", 2051: address => $d->{web_abuse}, 2052: note => sprintf('Hosting %s (%s, %s)', 2053: $dom // '(unknown domain)', 2054: $d->{web_ip} // '(unknown IP)', 2055: $d->{web_org} // '(unknown org)'), 2056: via => 'ip-whois', 2057: ); 2058: } 2059: 2060: # MX (mail host) contact 2061: if ($d->{mx_abuse}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2045_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2062: $add->( 2063: role => "Mail host (MX) for $dom", 2064: address => $d->{mx_abuse}, 2065: note => sprintf('MX %s (%s, %s)', 2066: $d->{mx_host} // '(unknown host)', 2067: $d->{mx_ip} // '(unknown IP)', 2068: $d->{mx_org} // '(unknown org)'), 2069: via => 'ip-whois', 2070: ); 2071: } 2072: 2073: # NS (DNS host) contact 2074: if ($d->{ns_abuse}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2061_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2075: $add->( 2076: role => "DNS host (NS) for $dom", 2077: address => $d->{ns_abuse}, 2078: note => sprintf('NS %s (%s, %s)', 2079: $d->{ns_host} // '(unknown host)', 2080: $d->{ns_ip} // '(unknown IP)', 2081: $d->{ns_org} // '(unknown org)'), 2082: via => 'ip-whois', 2083: ); 2084: } 2085: 2086: # Domain registrar (skip if domain only seen in spoofable headers) 2087: if ($d->{registrar_abuse}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2074_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2088: my $spoofable_only = 2089: $d->{source} =~ /^(?:From:|Return-Path:|Sender:) header$/ && 2090: !scalar(grep { 2091: $_->{host} && 2092: _registrable($_->{host}) eq (_registrable($dom) // $dom) 2093: } $self->embedded_urls()); 2094: unless ($spoofable_only) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2087_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2095: $add->( 2096: role => "Domain registrar for $dom", 2097: address => $d->{registrar_abuse}, 2098: note => 'Registrar: ' . ($d->{registrar} // '(unknown)'), 2099: via => 'domain-whois', 2100: ); 2101: } 2102: } 2103: } 2104: 2105: # Route 4 -- From:/Reply-To:/Return-Path:/Sender: account provider ●2106 → 2106 → 2131 2106: for my $hname (qw(from reply-to return-path sender)) { 2107: my $val = $self->_header_value($hname) // next; 2108: 2109: # Extract addr-spec from angle-bracket form to avoid display-name @-signs 2110: my $addr_spec = ($val =~ /<([^>]*)>\s*$/) ? $1 : $val; 2111: my ($addr_domain) = $addr_spec =~ /\@([\w.-]+)/; 2112: next unless $addr_domain; 2113: 2114: # Skip SRS-rewritten forwarder addresses (not the real sender) 2115: next if $addr_spec =~ /\+SRS[0-9]?=/i; 2116: 2117: my $pa = $self->_provider_abuse_for_host($addr_domain); 2118: if ($pa) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2094_4: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes2119: my $role_addr = $addr_spec =~ /\@/ ? $addr_spec : $val; 2120: $role_addr =~ s/^\s+|\s+$//g; 2121: $add->( 2122: role => "Account provider ($hname: $role_addr)", 2123: address => $pa->{email}, 2124: note => $pa->{note}, 2125: via => 'provider-table', 2126: ); 2127: } 2128: } 2129: 2130: # Route 5 -- DKIM signing organisation ●2131 → 2132 → 2145 2131: my $auth = $self->_parse_auth_results_cached(); 2132: if ($auth->{dkim_domain}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2118_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2133: my $pa = $self->_provider_abuse_for_host($auth->{dkim_domain}); 2134: if ($pa) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2132_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2135: $add->( 2136: role => "DKIM signer: $auth->{dkim_domain}", 2137: address => $pa->{email}, 2138: note => $pa->{note}, 2139: via => 'provider-table', 2140: ); 2141: } 2142: } 2143: 2144: # Route 6 -- List-Unsubscribe ESP domain ●2145 → 2146 → 2169 2145: my $unsub = $self->_header_value('list-unsubscribe'); 2146: if ($unsub) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2134_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2147: my @unsub_domains; 2148: while ($unsub =~ m{https?://([^/:?\s>]+)}gi) { 2149: push @unsub_domains, lc $1; 2150: } 2151: while ($unsub =~ m{mailto:[^@\s>]+\@([\w.-]+)}gi) { 2152: push @unsub_domains, lc $1; 2153: } 2154: my %unsub_seen; 2155: for my $dom (grep { !$unsub_seen{$_}++ } @unsub_domains) { 2156: my $pa = $self->_provider_abuse_for_host($dom); 2157: if ($pa) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2146_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2158: $add->( 2159: role => "ESP / bulk sender (List-Unsubscribe: $dom)", 2160: address => $pa->{email}, 2161: note => "$pa->{note} -- responsible for this bulk delivery", 2162: via => 'provider-table', 2163: ); 2164: } 2165: } 2166: } 2167: 2168: # Route 7 -- Reply addresses embedded in the message body ●2169 → 2171 → 2185 2169: my %body_addr_seen; 2170: my $combined_body = $self->{_body_plain} . "\n" . $self->{_body_html}; 2171: for my $addr_dom ($self->_domains_from_text($combined_body)) { 2172: next if $body_addr_seen{$addr_dom}++; 2173: my $pa = $self->_provider_abuse_for_host($addr_dom); 2174: next unless $pa && $pa->{email}; 2175: my ($example_addr) = $combined_body =~ /(\S+\@\Q$addr_dom\E)/i; 2176: $example_addr //= "\@$addr_dom"; 2177: $add->( 2178: role => "Reply address in body ($example_addr)", 2179: address => $pa->{email}, 2180: note => $pa->{note}, 2181: via => 'provider-table', 2182: ); 2183: } 2184: 2185: return @contacts;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2157_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2186: } 2187: 2188: # ----------------------------------------------------------------------- 2189: # Public: form contacts (providers that require web-form submission) 2190: # ----------------------------------------------------------------------- 2191: 2192: =head2 form_contacts() 2193: 2194: Returns the list of parties that require abuse reports via a web form 2195: rather than email. These are providers whose C<%PROVIDER_ABUSE> entry 2196: has a C<form> key. Each hashref includes the form URL, paste 2197: instructions, upload instructions, and the discovery role. 2198: 2199: =head3 Usage 2200: 2201: my @forms = $analyser->form_contacts(); 2202: for my $c (@forms) { 2203: printf "Open: %s\n", $c->{form}; 2204: } 2205: 2206: =head3 Arguments 2207: 2208: None. C<parse_email()> must have been called first. 2209: 2210: =head3 Returns 2211: 2212: A list of hashrefs, one per unique form contact. Each hashref has keys 2213: C<form>, C<role>, C<note>, C<form_paste> (optional), C<form_upload> 2214: (optional), and C<via>. Returns an empty list if no form contacts are found. 2215: 2216: =head3 Side Effects 2217: 2218: Triggers C<originating_ip()>, C<embedded_urls()>, and C<mailto_domains()> 2219: if not already cached. 2220: 2221: =head3 Notes 2222: 2223: Deduplication is by form URL. 2224: 2225: =head3 API Specification 2226: 2227: =head4 Input 2228: 2229: [] 2230: 2231: =head4 Output 2232: 2233: ( 2234: { 2235: type => 'hashref', 2236: keys => { 2237: form => { type => 'string', regex => qr{^https?://} }, 2238: role => { type => 'string' }, 2239: note => { type => 'string' }, 2240: form_paste => { type => 'string', optional => 1 }, 2241: form_upload => { type => 'string', optional => 1 }, 2242: via => { type => 'string' }, 2243: }, 2244: }, 2245: ... 2246: ) 2247: 2248: =cut 2249: 2250: sub form_contacts { ●2251 → 2266 → 2281 2251: my $self = $_[0]; 2252: 2253: my (@contacts, %seen); 2254: 2255: # Inner closure: add one form-contact entry, deduplicating by form URL 2256: my $add = sub { 2257: my (%args) = @_; 2258: my $form = $args{form} // ''; 2259: return unless $form; 2260: return if $seen{$form}++; 2261: push @contacts, \%args; 2262: }; 2263: 2264: # Route 1 -- Sending ISP 2265: my $orig = $self->originating_ip(); 2266: if ($orig) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2185_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2185_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2267: my $pa = $self->_provider_abuse_for_ip($orig->{ip}, $orig->{rdns}); 2268: if ($pa && $pa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2266_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2269: $add->( 2270: role => 'Sending ISP', 2271: form => $pa->{form}, 2272: note => $pa->{note} // '', 2273: form_paste => $pa->{form_paste} // '', 2274: form_upload => $pa->{form_upload} // '', 2275: via => 'provider-table', 2276: ); 2277: } 2278: } 2279: 2280: # Route 2 -- URL hosts ●2281 → 2282 → 2299 2281: my %url_host_seen; 2282: for my $u ($self->embedded_urls()) { 2283: next if $url_host_seen{ $u->{host} }++; 2284: my $pa = $self->_provider_abuse_for_host($u->{host}); 2285: if ($pa && $pa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2268_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2286: $add->( 2287: role => "URL host: $u->{host}", 2288: form => $pa->{form}, 2289: form_domain => $u->{host}, 2290: note => $pa->{note} // '', 2291: form_paste => $pa->{form_paste} // '', 2292: form_upload => $pa->{form_upload} // '', 2293: via => 'provider-table', 2294: ); 2295: } 2296: } 2297: 2298: # Route 3 -- Contact domains (web host + registrar) ●2299 → 2299 → 2333 2299: for my $d ($self->mailto_domains()) { 2300: my $dom = $d->{domain}; 2301: my $pa = $self->_provider_abuse_for_host($dom); 2302: if ($pa && $pa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2285_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2303: $add->( 2304: role => "Web host of $dom", 2305: form => $pa->{form}, 2306: form_domain => $dom, 2307: note => $pa->{note} // '', 2308: form_paste => $pa->{form_paste} // '', 2309: form_upload => $pa->{form_upload} // '', 2310: via => 'provider-table', 2311: ); 2312: } 2313: 2314: # Registrar identified via WHOIS -- check for form-only registrar 2315: if ($d->{registrar_abuse} && $d->{registrar_abuse} =~ /\@([\w.-]+)/) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2302_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2316: my $reg_domain = lc $1; 2317: my $rpa = $self->_provider_abuse_for_host($reg_domain); 2318: if ($rpa && $rpa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2315_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2319: $add->( 2320: role => "Domain registrar for $dom (web form only)", 2321: form => $rpa->{form}, 2322: form_domain => $dom, 2323: note => $rpa->{note} // '', 2324: form_paste => $rpa->{form_paste} // '', 2325: form_upload => $rpa->{form_upload} // '', 2326: via => 'provider-table', 2327: ); 2328: } 2329: } 2330: } 2331: 2332: # Route 4 -- Account provider headers ●2333 → 2333 → 2356 2333: for my $hname (qw(from reply-to return-path sender)) { 2334: my $val = $self->_header_value($hname) // next; 2335: my $addr_spec = ($val =~ /<([^>]*)>\s*$/) ? $1 : $val; 2336: my ($addr_domain) = $addr_spec =~ /\@([\w.-]+)/; 2337: next unless $addr_domain; 2338: # Skip SRS forwarder rewrite addresses 2339: next if $addr_spec =~ /\+SRS[0-9]?=/i; 2340: my $pa = $self->_provider_abuse_for_host($addr_domain); 2341: if ($pa && $pa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2318_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2342: my $role_addr = $addr_spec =~ /@/ ? $addr_spec : $val; 2343: $role_addr =~ s/^\s+|\s+$//g; 2344: $add->( 2345: role => "Account provider ($hname: $role_addr)", 2346: form => $pa->{form}, 2347: note => $pa->{note} // '', 2348: form_paste => $pa->{form_paste} // '', 2349: form_upload => $pa->{form_upload} // '', 2350: via => 'provider-table', 2351: ); 2352: } 2353: } 2354: 2355: # Route 5 -- DKIM signer ●2356 → 2357 → 2372 2356: my $auth = $self->_parse_auth_results_cached(); 2357: if ($auth->{dkim_domain}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2341_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2358: my $pa = $self->_provider_abuse_for_host($auth->{dkim_domain}); 2359: if ($pa && $pa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2357_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2360: $add->( 2361: role => "DKIM signer: $auth->{dkim_domain}", 2362: form => $pa->{form}, 2363: note => $pa->{note} // '', 2364: form_paste => $pa->{form_paste} // '', 2365: form_upload => $pa->{form_upload} // '', 2366: via => 'provider-table', 2367: ); 2368: } 2369: } 2370: 2371: # Route 6 -- List-Unsubscribe ESP domains ●2372 → 2373 → 2393 2372: my $unsub = $self->_header_value('list-unsubscribe'); 2373: if ($unsub) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2359_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2374: my @unsub_domains; 2375: while ($unsub =~ m{https?://([^/:?\s>]+)}gi) { push @unsub_domains, lc $1 } 2376: while ($unsub =~ m{mailto:[^@\s>]+\@([\w.-]+)}gi) { push @unsub_domains, lc $1 } 2377: my %useen; 2378: for my $dom (grep { !$useen{$_}++ } @unsub_domains) { 2379: my $pa = $self->_provider_abuse_for_host($dom); 2380: if ($pa && $pa->{form}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2373_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2381: $add->( 2382: role => "ESP / bulk sender (List-Unsubscribe: $dom)", 2383: form => $pa->{form}, 2384: note => $pa->{note} // '', 2385: form_paste => $pa->{form_paste} // '', 2386: form_upload => $pa->{form_upload} // '', 2387: via => 'provider-table', 2388: ); 2389: } 2390: } 2391: } 2392: 2393: return @contacts;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2380_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2394: } 2395: 2396: # ----------------------------------------------------------------------- 2397: # Public: full analyst report 2398: # ----------------------------------------------------------------------- 2399: 2400: =head2 report() 2401: 2402: Produces a comprehensive, analyst-facing plain-text report covering all 2403: findings: envelope fields, risk assessment, originating host, sending 2404: software, received chain tracking IDs, embedded URLs, contact domain 2405: intelligence, and recommended abuse contacts. 2406: 2407: Use C<report()> for human review or ticketing systems. Use 2408: C<abuse_report_text()> for sending to ISP abuse desks. 2409: 2410: =head3 Usage 2411: 2412: print $analyser->report(); 2413: 2414: open my $fh, '>', 'report.txt' or croak "Cannot open: $!"; 2415: print $fh $analyser->report(); 2416: close $fh; 2417: 2418: =head3 Arguments 2419: 2420: None. C<parse_email()> must have been called first. 2421: 2422: =head3 Returns 2423: 2424: A plain scalar string, newline-terminated, Unix line endings. Never empty 2425: or undef. 2426: 2427: =head3 Side Effects 2428: 2429: Triggers all analysis methods if not already cached. 2430: 2431: =head3 Notes 2432: 2433: The report is idempotent: calling it multiple times on the same object 2434: always returns an identical string. All user-derived content is sanitised 2435: before output. 2436: 2437: =head3 API Specification 2438: 2439: =head4 Input 2440: 2441: [] 2442: 2443: =head4 Output 2444: 2445: { type => 'string' } 2446: 2447: =cut 2448: 2449: sub report { ●2450 → 2461 → 2469 2450: my $self = $_[0]; 2451: 2452: my @out; 2453: 2454: # Banner header 2455: push @out, '=' x 72; 2456: push @out, " Email::Abuse::Investigator Report (v$VERSION)"; 2457: push @out, '=' x 72; 2458: push @out, ''; 2459: 2460: # Envelope summary -- decode MIME encoded-words for readability 2461: for my $f (qw(from reply-to return-path subject date message-id)) { 2462: my $v = $self->_header_value($f); 2463: next unless defined $v; 2464: my $decoded = $self->_decode_mime_words($v); 2465: my $label = ucfirst($f); 2466: push @out, sprintf(' %-14s : %s', $label, 2467: _sanitise_output($decoded ne $v ? "$decoded [encoded: $v]" : $v)); 2468: } ●2469 → 2474 → 2481 2469: push @out, ''; 2470: 2471: # Risk assessment section 2472: my $risk = $self->risk_assessment(); 2473: push @out, "[ RISK ASSESSMENT: $risk->{level} (score: $risk->{score}) ]"; 2474: if (@{ $risk->{flags} }) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2393_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2393_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2475: for my $f (@{ $risk->{flags} }) { 2476: push @out, " [$f->{severity}] " . _sanitise_output($f->{detail}); 2477: } 2478: } else { 2479: push @out, ' (no specific red flags detected)'; 2480: } ●2481 → 2485 → 2496 2481: push @out, ''; 2482: 2483: # Originating host section 2484: push @out, '[ ORIGINATING HOST ]'; 2485: if(my $orig = $self->originating_ip()) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2474_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2486: push @out, ' IP : ' . _sanitise_output($orig->{ip}); 2487: push @out, ' Reverse DNS : ' . _sanitise_output($orig->{rdns}) if $orig->{rdns}; 2488: push @out, ' Country : ' . _sanitise_output($orig->{country}) if $orig->{country}; 2489: push @out, ' Organisation : ' . _sanitise_output($orig->{org}) if $orig->{org}; 2490: push @out, ' Abuse addr : ' . _sanitise_output($orig->{abuse}) if $orig->{abuse}; 2491: push @out, " Confidence : $orig->{confidence}"; 2492: push @out, ' Note : ' . _sanitise_output($orig->{note}) if $orig->{note}; 2493: } else { 2494: push @out, ' (could not determine originating IP)'; 2495: } ●2496 → 2500 → 2510 2496: push @out, ''; 2497: 2498: # Sending software section (omitted if none found) 2499: my @sw = $self->sending_software(); 2500: if (@sw) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2485_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2501: push @out, '[ SENDING SOFTWARE / INFRASTRUCTURE CLUES ]'; 2502: for my $s (@sw) { 2503: push @out, sprintf(' %-14s : %s', $s->{header}, _sanitise_output($s->{value})); 2504: push @out, " Note : $s->{note}"; 2505: push @out, ''; 2506: } 2507: } 2508: 2509: # Received chain tracking IDs (only hops with id or for are shown) ●2510 → 2512 → 2525 2510: my @trail = grep { defined $_->{id} || defined $_->{for} } 2511: $self->received_trail(); 2512: if (@trail) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2500_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2513: push @out, '[ RECEIVED CHAIN TRACKING IDs ]'; 2514: push @out, ' (Supply these to the relevant ISP abuse team to trace the session)'; 2515: push @out, ''; 2516: for my $hop (@trail) { 2517: push @out, ' IP : ' . (_sanitise_output($hop->{ip}) // '(unknown)'); 2518: push @out, ' Envelope for : ' . _sanitise_output($hop->{for}) if $hop->{for}; 2519: push @out, ' Server ID : ' . _sanitise_output($hop->{id}) if $hop->{id}; 2520: push @out, ''; 2521: } 2522: } 2523: 2524: # Embedded URLs section -- grouped by hostname ●2525 → 2527 → 2570 2525: push @out, '[ EMBEDDED HTTP/HTTPS URLs ]'; 2526: my @urls = $self->embedded_urls(); 2527: if (@urls) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2512_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2528: my (%host_order, %host_meta, %host_paths); 2529: my $seq = 0; 2530: for my $u (@urls) { 2531: my $h = $u->{host}; 2532: unless (exists $host_order{$h}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2527_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2533: $host_order{$h} = $seq++; 2534: $host_meta{$h} = { 2535: ip => $u->{ip}, 2536: org => $u->{org}, 2537: abuse => $u->{abuse}, 2538: country => $u->{country}, 2539: }; 2540: } 2541: push @{ $host_paths{$h} }, $u->{url}; 2542: } 2543: 2544: # Output each host group in first-seen order 2545: for my $h (sort { $host_order{$a} <=> $host_order{$b} } keys %host_order) { 2546: my $m = $host_meta{$h}; 2547: my $bare = lc $h; $bare =~ s/^www\.//; 2548: push @out, ' Host : ' . _sanitise_output($h) . 2549: (($URL_SHORTENERS{$bare} || $self->{url_shorteners}->{$bare}) 2550: ? ' *** URL SHORTENER -- real destination hidden ***' : ''); 2551: push @out, ' IP : ' . _sanitise_output($m->{ip}) if $m->{ip}; 2552: push @out, ' Country : ' . _sanitise_output($m->{country}) if $m->{country}; 2553: push @out, ' Organisation : ' . _sanitise_output($m->{org}) if $m->{org}; 2554: push @out, ' Abuse addr : ' . _sanitise_output($m->{abuse}) if $m->{abuse}; 2555: my @paths = @{ $host_paths{$h} }; 2556: if (@paths == 1) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2532_4: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes2557: push @out, ' URL : ' . _sanitise_output($paths[0]); 2558: } else { 2559: push @out, ' URLs (' . scalar(@paths) . ') :'; 2560: push @out, ' ' . _sanitise_output($_) for @paths; 2561: } 2562: push @out, ''; 2563: } 2564: } else { 2565: push @out, ' (none found)'; 2566: push @out, ''; 2567: } 2568: 2569: # Contact / reply-to domains section ●2570 → 2572 → 2612 2570: push @out, '[ CONTACT / REPLY-TO DOMAINS ]'; 2571: my @mdoms = $self->mailto_domains(); 2572: if (@mdoms) {Mutants (Total: 2, Killed: 0, Survived: 2)
- NUM_BOUNDARY_2556_15_!=: Numeric boundary flip == to !=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_2556_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2573: for my $d (@mdoms) { 2574: push @out, ' Domain : ' . _sanitise_output($d->{domain}); 2575: push @out, ' Found in : ' . _sanitise_output($d->{source}); 2576: if ($d->{recently_registered}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2572_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2577: push @out, ' *** WARNING: RECENTLY REGISTERED - possible phishing domain ***'; 2578: } 2579: push @out, ' Registered : ' . $d->{registered} if $d->{registered}; 2580: push @out, ' Expires : ' . $d->{expires} if $d->{expires}; 2581: push @out, ' Registrar : ' . _sanitise_output($d->{registrar}) if $d->{registrar}; 2582: push @out, ' Reg. abuse : ' . _sanitise_output($d->{registrar_abuse}) if $d->{registrar_abuse}; 2583: if ($d->{web_ip}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2576_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2584: push @out, ' Web host IP : ' . _sanitise_output($d->{web_ip}); 2585: push @out, ' Web host org : ' . _sanitise_output($d->{web_org}) if $d->{web_org}; 2586: push @out, ' Web abuse : ' . _sanitise_output($d->{web_abuse}) if $d->{web_abuse}; 2587: } else { 2588: push @out, ' Web host : (no A record / unreachable)'; 2589: } 2590: if ($d->{mx_host}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2583_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2591: push @out, ' MX host : ' . _sanitise_output($d->{mx_host}); 2592: push @out, ' MX IP : ' . _sanitise_output($d->{mx_ip}) if $d->{mx_ip}; 2593: push @out, ' MX org : ' . _sanitise_output($d->{mx_org}) if $d->{mx_org}; 2594: push @out, ' MX abuse : ' . _sanitise_output($d->{mx_abuse}) if $d->{mx_abuse}; 2595: } else { 2596: push @out, ' MX host : (none found)'; 2597: } 2598: if ($d->{ns_host}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2590_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2599: push @out, ' NS host : ' . _sanitise_output($d->{ns_host}); 2600: push @out, ' NS IP : ' . _sanitise_output($d->{ns_ip}) if $d->{ns_ip}; 2601: push @out, ' NS org : ' . _sanitise_output($d->{ns_org}) if $d->{ns_org}; 2602: push @out, ' NS abuse : ' . _sanitise_output($d->{ns_abuse}) if $d->{ns_abuse}; 2603: } 2604: push @out, ''; 2605: } 2606: } else { 2607: push @out, ' (none found)'; 2608: push @out, ''; 2609: } 2610: 2611: # Abuse contacts summary ●2612 → 2614 → 2628 2612: push @out, '[ WHERE TO SEND ABUSE REPORTS ]'; 2613: my @contacts = $self->abuse_contacts(); 2614: if (@contacts) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2598_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2615: for my $c (@contacts) { 2616: push @out, ' Role : ' . _sanitise_output($c->{role}); 2617: push @out, ' Send to : ' . _sanitise_output($c->{address}); 2618: push @out, ' Note : ' . _sanitise_output($c->{note}) if $c->{note}; 2619: push @out, " Discovered : $c->{via}"; 2620: push @out, ''; 2621: } 2622: } else { 2623: push @out, ' (no abuse contacts could be determined)'; 2624: push @out, ''; 2625: } 2626: 2627: # Web-form contacts (providers that require manual form submission) ●2628 → 2629 → 2661 2628: my @form_cs = $self->form_contacts(); 2629: if (@form_cs) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2614_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2630: push @out, '[ WHERE TO FILE WEB-FORM REPORTS ]'; 2631: push @out, ' The following parties require manual submission via a web form.'; 2632: push @out, ' Open each URL in a browser, then follow the instructions below it.'; 2633: push @out, ''; 2634: for my $c (@form_cs) { 2635: push @out, ' Role : ' . _sanitise_output($c->{role}); 2636: push @out, ' Form URL : ' . _sanitise_output($c->{form}); 2637: push @out, ' Domain/URL : ' . _sanitise_output($c->{form_domain}) if $c->{form_domain}; 2638: push @out, ' Note : ' . _sanitise_output($c->{note}) if $c->{note}; 2639: if ($c->{form_paste}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2629_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2640: # Word-wrap the paste hint at ROLE_WRAP_LEN characters 2641: my $hint = $c->{form_paste}; 2642: my @words = split /\s+/, $hint; 2643: my (@lines, $line); 2644: for my $w (@words) { 2645: if (defined $line && length("$line $w") > $ROLE_WRAP_LEN) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2639_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2646: push @lines, $line; 2647: $line = $w; 2648: } else { 2649: $line = defined $line ? "$line $w" : $w; 2650: } 2651: } 2652: push @lines, $line if defined $line; 2653: push @out, ' Paste : ' . shift @lines if @lines; 2654: push @out, ' ' . $_ for @lines; 2655: } 2656: push @out, ' Upload : ' . _sanitise_output($c->{form_upload}) if $c->{form_upload}; 2657: push @out, ''; 2658: } 2659: } 2660: 2661: push @out, '=' x 72; 2662: return join("\n", @out) . "\n";Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_2645_46_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_2645_46_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_2645_46_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_2645_6: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2663: } 2664: 2665: # ----------------------------------------------------------------------- 2666: # Private: output sanitisation 2667: # ----------------------------------------------------------------------- 2668: 2669: # _sanitise_output( $str ) -> $str 2670: # 2671: # Purpose: 2672: # Strip control characters that could affect terminal rendering or HTML 2673: # injection from any string that will appear in a report or abuse email. 2674: # Preserves printable ASCII, high-bytes (for UTF-8 content), tabs, and 2675: # line endings. 2676: # 2677: # Entry criteria: 2678: # $str -- a defined or undef scalar. 2679: # 2680: # Exit status: 2681: # Returns the sanitised string, or the empty string if $str is undef. 2682: # 2683: # Notes: 2684: # Only strips C0 control characters below 0x20 (except \t) and the DEL 2685: # character (0x7F). High bytes (0x80-0xFF) are preserved because they 2686: # form valid UTF-8 multi-byte sequences in headers and body text. 2687: 2688: sub _sanitise_output :Private { 2689: my $str = $_[0]; 2690: return '' unless defined $str;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2662_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2662_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2691: # Remove C0 controls (except tab) and DEL 2692: $str =~ s/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]//g; 2693: return $str;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2690_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2690_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2694: } 2695: 2696: # ----------------------------------------------------------------------- 2697: # Private: message parsing 2698: # ----------------------------------------------------------------------- 2699: 2700: # _split_message( $text ) 2701: # 2702: # Purpose: 2703: # Split a raw RFC 2822 email into headers and body, parse all headers, 2704: # decode the body (including multipart), extract sending-software 2705: # fingerprints, and populate per-hop tracking data. 2706: # 2707: # Entry criteria: 2708: # $text -- defined scalar, already dereferenced by parse_email(). 2709: # $self->{_sending_sw} and $self->{_rcvd_tracking} reset to [] by caller. 2710: # 2711: # Exit status: 2712: # Returns undef silently if the header block is empty/whitespace-only. 2713: # Otherwise all results are communicated via side effects on $self. 2714: # 2715: # Side effects: 2716: # Populates _headers, _received, _body_plain, _body_html, _sending_sw, 2717: # and _rcvd_tracking. 2718: # 2719: # Notes: 2720: # Delegates to _decode_multipart() for multipart/* content types. 2721: # Lines not matching the header pattern are silently discarded. 2722: # Boundary extraction uses a simple regex; missing boundary causes the 2723: # body to be skipped silently. 2724: 2725: sub _split_message :Private { ●2726 → 2739 → 2744 2726: my ($self, $text) = @_; 2727: 2728: # Split at the first blank line (RFC 2822 header/body separator) 2729: my ($header_block, $body_raw) = split /\r?\n\r?\n/, $text, 2; 2730: 2731: return unless defined $header_block && $header_block =~ /\S/; 2732: $body_raw //= ''; 2733: 2734: # Unfold RFC 2822 continuation lines (s2.2.3) 2735: $header_block =~ s/\r?\n([ \t]+)/ $1/g; 2736: 2737: # Parse each header line into a { name, value } pair 2738: my @headers; 2739: for my $line (split /\r?\n/, $header_block) { 2740: if ($line =~ /^([\w-]+)\s*:\s*(.*)/) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2693_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2693_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2741: push @headers, { name => lc($1), value => $2 }; 2742: } 2743: } ●2744 → 2759 → 2769 2744: $self->{_headers} = \@headers; 2745: 2746: # Collect all Received: header values (most-recent first, as in message) 2747: $self->{_received} = [ 2748: map { $_->{value} } 2749: grep { $_->{name} eq 'received' } @headers 2750: ]; 2751: 2752: # Determine content type and transfer encoding from top-level headers 2753: my ($ct_h) = grep { $_->{name} eq 'content-type' } @headers; 2754: my ($cte_h) = grep { $_->{name} eq 'content-transfer-encoding' } @headers; 2755: my $ct = defined $ct_h ? $ct_h->{value} : ''; 2756: my $cte = defined $cte_h ? $cte_h->{value} : ''; 2757: 2758: # Decode multipart or single-part body as appropriate 2759: if ($ct =~ /multipart/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2740_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2760: my ($boundary) = $ct =~ /boundary="?([^";]+)"?/i; 2761: # Pass depth=0 to enforce the MAX_MULTIPART_DEPTH recursion guard 2762: $self->_decode_multipart($body_raw, $boundary, 0) if $boundary; 2763: } else { 2764: my $decoded = $self->_decode_body($body_raw, $cte); 2765: if ($ct =~ /html/i) { $self->{_body_html} = $decoded }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2759_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2766: else { $self->{_body_plain} = $decoded } 2767: } 2768: ●2769 → 2783 → 2795 2769: $self->_debug(sprintf 'Parsed %d headers, %d Received lines', 2770: scalar @headers, scalar @{ $self->{_received} }); 2771: 2772: # --- Sending software fingerprints --- 2773: # These headers identify the mailer or shared-hosting script that sent 2774: # the message; invaluable for shared-hosting abuse reports. 2775: my %sw_notes = ( 2776: 'x-php-originating-script' => 'PHP script on shared hosting -- report to hosting abuse team', 2777: 'x-source' => 'Source file on shared hosting -- report to hosting abuse team', 2778: 'x-source-host' => 'Sending hostname injected by shared hosting provider', 2779: 'x-source-args' => 'Command-line args injected by shared hosting provider', 2780: 'x-mailer' => 'Email client or bulk-mailer identifier', 2781: 'user-agent' => 'Email client identifier', 2782: ); 2783: for my $sw_hdr (sort keys %sw_notes) { 2784: my ($h) = grep { $_->{name} eq $sw_hdr } @headers; 2785: next unless $h; 2786: push @{ $self->{_sending_sw} }, { 2787: header => $sw_hdr, 2788: value => $h->{value}, 2789: note => $sw_notes{$sw_hdr}, 2790: }; 2791: } 2792: 2793: # --- Per-hop tracking IDs from Received: chain --- 2794: # Walk oldest-first (reverse) so _rcvd_tracking is oldest-first ●2795 → 2795 → 0 2795: for my $rcvd (reverse @{ $self->{_received} }) { 2796: my $ip = $self->_extract_ip_from_received($rcvd); 2797: my ($for_addr) = $rcvd =~ /\bfor\s+<?([^\s>]+\@[\w.-]+\.[\w]+)>?/i; 2798: my ($srv_id) = $rcvd =~ /\bid\s+([\w.-]+)/i; 2799: # Skip hops with no actionable tracking data 2800: next unless defined $ip || defined $for_addr || defined $srv_id; 2801: push @{ $self->{_rcvd_tracking} }, { 2802: received => $rcvd, 2803: ip => $ip, 2804: for => $for_addr, 2805: id => $srv_id, 2806: }; 2807: } 2808: } 2809: 2810: # _decode_multipart( $body, $boundary, $depth ) 2811: # 2812: # Purpose: 2813: # Recursively split a MIME multipart body on its boundary and decode each 2814: # text/plain and text/html part. Nested multipart/* containers are 2815: # recursed into up to MAX_MULTIPART_DEPTH levels deep. 2816: # 2817: # Entry criteria: 2818: # $body -- the raw body text of the multipart container. 2819: # $boundary -- the boundary string from the Content-Type header. 2820: # $depth -- current recursion depth (starts at 0 from _split_message). 2821: # 2822: # Exit status: 2823: # Returns undef if $depth >= MAX_MULTIPART_DEPTH (recursion guard). 2824: # Otherwise all results via side effects. 2825: # 2826: # Side effects: 2827: # Appends decoded text to $self->{_body_plain} and $self->{_body_html}. 2828: # 2829: # Notes: 2830: # Whitespace-only MIME segments between boundaries are silently skipped. 2831: # Decoding errors are silenced; raw bytes are used as fallback. 2832: 2833: sub _decode_multipart :Private { ●2834 → 2839 → 2846 2834: my ($self, $body, $boundary, $depth) = @_; 2835: $depth //= 0; 2836: 2837: # Enforce the recursion depth limit to prevent stack exhaustion on 2838: # pathological crafted messages with deeply nested multipart structures. 2839: if ($depth >= $MAX_MULTIPART_DEPTH) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2765_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2840: Carp::carp 'Email::Abuse::Investigator: multipart nesting depth limit', 2841: "($MAX_MULTIPART_DEPTH) exceeded; stopping recursion"; 2842: return; 2843: } 2844: 2845: # Split on the boundary marker; the (?:--)? suffix handles closing boundary ●2846 → 2848 → 0 2846: my @parts = split /--\Q$boundary\E(?:--)?/, $body; 2847: 2848: for my $part (@parts) { 2849: # Skip whitespace-only segments between boundaries 2850: next unless $part =~ /\S/; 2851: 2852: $part =~ s/^\r?\n//; 2853: 2854: # Each MIME part has its own headers separated from body by a blank line 2855: my ($phdr_block, $pbody) = split /\r?\n\r?\n/, $part, 2; 2856: next unless defined $pbody; 2857: 2858: # Unfold continuation header lines within this part 2859: $phdr_block =~ s/\r?\n([ \t]+)/ $1/g; 2860: 2861: # Parse this part's headers into a simple hash 2862: my %phdr; 2863: for my $line (split /\r?\n/, $phdr_block) { 2864: $phdr{ lc($1) } = $2 if $line =~ /^([\w-]+)\s*:\s*(.*)/; 2865: } 2866: 2867: my $pct = $phdr{'content-type'} // ''; 2868: my $pcte = $phdr{'content-transfer-encoding'} // ''; 2869: 2870: # Nested multipart/* must be recursed into; without this URLs in 2871: # multipart/alternative inside multipart/mixed would be missed. 2872: if ($pct =~ /multipart/i) {Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_2839_13_>: Numeric boundary flip >= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_2839_13_<: Numeric boundary flip >= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_2839_13_<=: Numeric boundary flip >= to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_2839_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2873: my ($inner_boundary) = $pct =~ /boundary\s*=\s*"?([^";]+)"?/i; 2874: if ($inner_boundary) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2872_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2875: $inner_boundary =~ s/\s+$//; 2876: # Increment depth counter for the recursion guard 2877: $self->_decode_multipart($pbody, $inner_boundary, $depth + 1); 2878: } 2879: next; 2880: } 2881: 2882: # Decode transfer encoding and accumulate by content type 2883: my $decoded = $self->_decode_body($pbody, $pcte); 2884: if ($pct =~ /text\/html/i) { $self->{_body_html} .= $decoded }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2874_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2885: elsif ($pct =~ /text/i || !$pct) { $self->{_body_plain} .= $decoded } 2886: } 2887: } 2888: 2889: # _decode_body( $body, $cte ) -> string 2890: # 2891: # Purpose: 2892: # Decode a MIME body part according to its Content-Transfer-Encoding. 2893: # 2894: # Entry criteria: 2895: # $body -- raw body string (may be undef). 2896: # $cte -- Content-Transfer-Encoding value string (may be undef). 2897: # 2898: # Exit status: 2899: # Returns the decoded string, or the original string if the encoding is 2900: # 7bit/8bit/binary or unrecognised. 2901: # 2902: # Notes: 2903: # decode_qp and decode_base64 are imported from MIME:: modules; errors 2904: # from malformed content are silenced by the eval wrappers they provide. 2905: 2906: sub _decode_body :Private { 2907: my ($self, $body, $cte) = @_; 2908: $cte //= ''; 2909: return decode_qp($body) if $cte =~ /quoted-printable/i;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2884_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2910: return decode_base64($body) if $cte =~ /base64/i;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2909_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2909_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2911: return $body // '';Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2910_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2910_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2912: } 2913: 2914: # ----------------------------------------------------------------------- 2915: # Private: Received-chain -> originating IP 2916: # ----------------------------------------------------------------------- 2917: 2918: # _find_origin() 2919: # 2920: # Purpose: 2921: # Walk the Received: chain (oldest-first) to find the first external IP, 2922: # or fall back to X-Originating-IP. Enrich with rDNS and WHOIS. 2923: # 2924: # Entry criteria: 2925: # $self->{_received} populated by _split_message(). 2926: # $self->{trusted_relays} set by new(). 2927: # 2928: # Exit status: 2929: # Returns { ip, rdns, org, abuse, country, confidence, note } on success. 2930: # Returns undef if no usable IP can be identified. 2931: # 2932: # Side effects: 2933: # Network I/O via _enrich_ip(): one PTR lookup, one RDAP/WHOIS query. 2934: # Results are also stored in the CHI cross-message cache if available. 2935: # 2936: # Notes: 2937: # confidence 'high' = 2+ distinct external IPs; 2938: # 'medium' = exactly one external IP; 2939: # 'low' = taken from X-Originating-IP. 2940: 2941: sub _find_origin :Private { ●2942 → 2947 → 2955 2942: my $self = $_[0]; 2943: 2944: my @candidates; 2945: 2946: # Walk oldest-first (reverse) to collect external IPs 2947: for my $hdr (reverse @{ $self->{_received} }) { 2948: my $ip = $self->_extract_ip_from_received($hdr) // next; 2949: next if $self->_is_private($ip); 2950: next if $self->_is_trusted($ip); 2951: push @candidates, $ip; 2952: } 2953: 2954: # Fall back to X-Originating-IP if no external IPs in Received: chain ●2955 → 2955 → 2967 2955: unless (@candidates) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2911_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2911_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2956: my $xoip = $self->_header_value('x-originating-ip'); 2957: if ($xoip) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2955_2: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes2958: $xoip =~ s/[\[\]\s]//g; 2959: return $self->_enrich_ip($xoip, 'low',Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2957_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2960: 'Taken from X-Originating-IP (webmail, unverified)') 2961: unless $self->_is_private($xoip); 2962: } 2963: return; 2964: } 2965: 2966: # Report the oldest (first) external IP; confidence depends on count 2967: return $self->_enrich_ip(Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2959_4: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2959_4: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2968: $candidates[0], 2969: @candidates > 1 ? 'high' : 'medium',Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2967_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2967_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );2970: 'First external hop in Received: chain', 2971: ); 2972: } 2973: 2974: # _extract_ip_from_received( $hdr ) -> ipv4_or_ipv6_string | undef 2975: # 2976: # Purpose: 2977: # Extract the most-significant IP address from a raw Received: header 2978: # value, trying patterns in priority order. Supports both IPv4 dotted- 2979: # quad and IPv6 bracket notation. 2980: # 2981: # Entry criteria: 2982: # $hdr -- a defined Received: header value string. 2983: # 2984: # Exit status: 2985: # Returns the IP string on success, undef if no IP can be extracted. 2986: # 2987: # Notes: 2988: # IPv4 addresses are validated (all octets <= 255). 2989: # IPv6 addresses are returned as-is if they contain colons. 2990: 2991: sub _extract_ip_from_received :Private { ●2992 → 2993 → 3006 2992: my ($self, $hdr) = @_; 2993: for my $re (@RECEIVED_IP_RE) { 2994: if ($hdr =~ $re) {Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_2969_15_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_2969_15_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_2969_15_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );2995: my $ip = $1; 2996: 2997: # Accept IPv6 addresses (contain colons) without further validation 2998: return $ip if $ip =~ /:/;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_2994_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes2999: 3000: # Validate IPv4 format and octet range 3001: next unless $ip =~ /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; 3002: next if grep { $_ > 255 } split /\./, $ip;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_2998_4: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_2998_4: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3003: return $ip;Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_3002_22_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3002_22_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3002_22_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );3004: } 3005: } 3006: return; 3007: } 3008: 3009: # _is_private( $ip ) -> bool 3010: # 3011: # Purpose: 3012: # Test whether an IP address falls in any private, reserved, or special- 3013: # use range (IPv4 or IPv6) that should never be reported as a spam origin. 3014: # 3015: # Entry criteria: 3016: # $ip -- a scalar IP string (IPv4 or IPv6); may be undef. 3017: # 3018: # Exit status: 3019: # Returns 1 (true) if the IP is private/reserved, 0 (false) otherwise. 3020: # Returns 1 for undef or empty strings. 3021: # 3022: # Notes: 3023: # Uses the module-level @PRIVATE_RANGES array of pre-compiled regexes. 3024: # Covers all ranges listed in RFC 1122, 1918, 5737, 6598, and RFC 4193. 3025: 3026: sub _is_private :Private { ●3027 → 3029 → 3030 3027: my ($self, $ip) = @_; 3028: return 1 if !defined($ip) || $ip eq '';Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3003_4: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3003_4: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3029: for my $re (@PRIVATE_RANGES) { return 1 if $ip =~ $re }Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3028_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3028_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3030: return 0;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3029_33: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3029_33: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3031: } 3032: 3033: # _is_trusted( $ip ) -> bool 3034: # 3035: # Purpose: 3036: # Test whether an IP address matches any entry in the caller-supplied 3037: # trusted_relays list (exact IP or CIDR block). 3038: # 3039: # Entry criteria: 3040: # $ip -- a defined IPv4 address string. 3041: # $self->{trusted_relays} -- arrayref of exact IPs or CIDR strings. 3042: # 3043: # Exit status: 3044: # Returns 1 (true) if the IP matches any trusted relay, 0 otherwise. 3045: 3046: sub _is_trusted :Private { ●3047 → 3048 → 3051 3047: my ($self, $ip) = @_; 3048: for my $cidr (@{ $self->{trusted_relays} }) { 3049: return 1 if $self->_ip_in_cidr($ip, $cidr);Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3030_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3030_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3050: } 3051: return 0;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3049_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3049_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3052: } 3053: 3054: # ----------------------------------------------------------------------- 3055: # Private: HTTP/HTTPS URL extraction and resolution 3056: # ----------------------------------------------------------------------- 3057: 3058: # _extract_and_resolve_urls() -> arrayref of url hashrefs 3059: # 3060: # Purpose: 3061: # Extract all HTTP/HTTPS URLs from the decoded body, resolve each unique 3062: # hostname to an IP, and enrich with WHOIS/RDAP data. Optionally uses 3063: # AnyEvent::DNS to parallelise the DNS resolution step. 3064: # 3065: # Entry criteria: 3066: # $self->{_body_plain} and $self->{_body_html} populated by _split_message(). 3067: # 3068: # Exit status: 3069: # Returns an arrayref of url hashrefs (possibly empty). 3070: # 3071: # Side effects: 3072: # Network I/O per unique hostname: one A/AAAA lookup, one RDAP/WHOIS. 3073: # Results stored in the CHI cross-message cache if available. 3074: 3075: sub _extract_and_resolve_urls :Private { ●3076 → 3090 → 3104 3076: my $self = $_[0]; 3077: my (%url_seen, %host_cache); 3078: my @results; 3079: my $combined = $self->{_body_plain} . "\n" . $self->{_body_html}; 3080: 3081: # Collect unique URLs from body 3082: my @urls = grep { !$url_seen{$_}++ } $self->_extract_http_urls($combined); 3083: 3084: # For URL-shortener and redirect-cloaker hosts, follow the redirect chain to 3085: # discover the real destination (e.g. GCS bucket â phishing landing page). 3086: # Done before DNS resolution so that destination hostnames can be parallelised. 3087: # _follow_redirect_chain() is a :Protected seam that handles LWP availability 3088: # internally â do not guard this block with $HAS_LWP so the seam can be 3089: # stubbed unconditionally in tests regardless of whether LWP is installed. 3090: for my $url (@urls) { 3091: my ($host) = $url =~ m{https?://([^/:?\s#]+)}i; 3092: next unless $host; 3093: my $bare = lc $host; 3094: $bare =~ s/^www\.//; 3095: next unless $URL_SHORTENERS{$bare} 3096: || ($self->{url_shorteners} && $self->{url_shorteners}{$bare}) 3097: || $self->_is_redirect_cloaker($bare); 3098: my $dest = $self->_follow_redirect_chain($url); 3099: next unless defined $dest && !$url_seen{$dest}++; 3100: push @urls, $dest; 3101: } 3102: 3103: # Extract unique hostnames for parallel DNS resolution (including redirect destinations) ●3104 → 3105 → 3111 3104: my %hostname_needed; 3105: for my $url (@urls) { 3106: my ($host) = $url =~ m{https?://([^/:?\s#]+)}i; 3107: $hostname_needed{$host}++ if $host; 3108: } 3109: 3110: # Parallelise DNS lookups if AnyEvent::DNS is available ●3111 → 3111 → 3116 3111: if ($HAS_ANYEVENT_DNS && scalar(keys %hostname_needed) > 1) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3051_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3051_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3112: $self->_parallel_resolve_hosts(\%hostname_needed, \%host_cache); 3113: } 3114: 3115: # Process each URL: resolve hostname and WHOIS-enrich ●3116 → 3116 → 3154 3116: for my $url (@urls) { 3117: my ($host) = $url =~ m{https?://([^/:?\s#]+)}i; 3118: next unless $host; 3119: 3120: # Resolve and WHOIS once per unique hostname, then cache the result 3121: unless (exists $host_cache{$host}) {Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_3111_57_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3111_57_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3111_57_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_3111_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3122: # Check the cross-message CHI cache first 3123: my $cached = $_cache ? $_cache->get("url:$host") : undef; 3124: if ($cached) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3121_3: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes3125: $host_cache{$host} = $cached; 3126: } else { 3127: my $ip = $self->_resolve_host($host) // '(unresolved)'; 3128: my $whois = $ip ne '(unresolved)' 3129: ? $self->_whois_ip($ip) 3130: : {}; 3131: 3132: # Fall back to domain WHOIS if IP lookup returned nothing 3133: if (!$whois->{abuse}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3124_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3134: my $reg = _registrable($host) // $host; 3135: my $dw = $self->_parse_domain_whois_abuse($reg); 3136: $whois = $dw if $dw->{abuse}; 3137: } 3138: 3139: my $entry = { 3140: ip => $ip, 3141: org => $whois->{org} // '(unknown)', 3142: abuse => $whois->{abuse} // '(unknown)', 3143: country => $whois->{country} // undef, 3144: }; 3145: $host_cache{$host} = $entry; 3146: 3147: # Store in cross-message cache for reuse across messages 3148: $_cache->set("url:$host", $entry) if $_cache; 3149: } 3150: } 3151: 3152: push @results, { url => $url, host => $host, %{ $host_cache{$host} } }; 3153: } 3154: return \@results;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3133_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3155: } 3156: 3157: # _is_redirect_cloaker( $bare_host ) -> bool 3158: # 3159: # Purpose: 3160: # Return true if $bare_host is a known cloud-storage or CDN host that is 3161: # commonly abused to serve client-side redirect pages hiding the real 3162: # phishing destination. Checks the exact-match %REDIRECT_HOSTS table and 3163: # the suffix patterns in @REDIRECT_HOST_SUFFIXES. 3164: # 3165: # Entry criteria: 3166: # $bare_host -- lowercase hostname with any leading "www." already stripped. 3167: # 3168: # Exit status: 3169: # Returns 1 (true) or empty string (false). 3170: 3171: sub _is_redirect_cloaker :Private { ●3172 → 3174 → 3178 3172: my (undef, $host) = @_; 3173: return 1 if $REDIRECT_HOSTS{$host};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3154_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3154_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3174: for my $suffix (@REDIRECT_HOST_SUFFIXES) { 3175: return 1 if length($host) > length($suffix)Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3173_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3173_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3176: && substr($host, -length($suffix)) eq $suffix; 3177: } 3178: return '';Mutants (Total: 5, Killed: 0, Survived: 5)
- NUM_BOUNDARY_3175_29_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3175_29_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3175_29_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- BOOL_NEGATE_3175_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3175_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3179: } 3180: 3181: # _follow_redirect_chain( $url ) -> $final_url | undef 3182: # 3183: # Purpose: 3184: # Follow up to $REDIRECT_MAX_HOPS HTTP hops for a given URL and return the 3185: # final destination. Detects HTTP 3xx Location: redirects, HTML 3186: # <meta http-equiv="refresh" content="...url=..."> tags, and 3187: # window.location.replace() / window.location.href JavaScript patterns. 3188: # Used to expose the real phishing landing page hidden behind a cloud 3189: # object-store redirect page (e.g. a GCS bucket containing only a 3190: # meta-refresh pointing to the attacker-controlled domain). 3191: # 3192: # Entry criteria: 3193: # $url -- an https?:// URL string whose host has already been identified 3194: # as a URL shortener or redirect cloaker. 3195: # LWP::UserAgent must be installed ($HAS_LWP must be true). 3196: # 3197: # Exit status: 3198: # Returns the first URL that differs from the input after following the 3199: # chain, or undef if no redirect was found or LWP is unavailable. 3200: # Never returns the input $url unchanged. 3201: # 3202: # Side effects: 3203: # Up to $REDIRECT_MAX_HOPS HTTP GET requests. 3204: # Successful result cached in the cross-message CHI cache keyed 3205: # "redirect:<url>" to avoid re-fetching across messages. 3206: 3207: sub _follow_redirect_chain :Protected { ●3208 → 3212 → 3219 3208: my ($self, $url) = @_; 3209: return undef unless $HAS_LWP;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3178_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3178_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3210: 3211: # Serve from cache when available 3212: if ($_cache) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3209_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3209_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3213: my $cached = $_cache->get("redirect:$url"); 3214: return $cached if defined $cached;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3212_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3215: } 3216: 3217: # Dedicated no-follow UA so we can inspect each redirect hop manually. 3218: # Stored separately from the RDAP UA ($self->{ua}) which needs auto-follow. ●[NOT COVERED] 3219 → 3219 → 3233 3219: unless (defined $self->{_ua_nofollow}) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3214_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3214_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3220: my $ua = LWP::UserAgent->new( 3221: timeout => $self->{timeout}, 3222: agent => "Email-Abuse-Investigator/$VERSION", 3223: max_redirect => 0, 3224: ); 3225: if ($HAS_CONN_CACHE) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3219_2: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes3226: my $cc = LWP::ConnCache->new(); 3227: $cc->total_capacity(4); 3228: $ua->conn_cache($cc); 3229: } 3230: $ua->env_proxy(1); 3231: $self->{_ua_nofollow} = $ua; 3232: } ●[NOT COVERED] 3233 → 3237 → 3278 3233: my $ua = $self->{_ua_nofollow}; 3234: 3235: my $current = $url; 3236: my $final; 3237: for my $hop (1 .. $REDIRECT_MAX_HOPS) { 3238: my $res = eval { $ua->get($current) }; 3239: last unless $res; 3240: 3241: if ($res->is_redirect()) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3225_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3242: # HTTP 3xx: extract Location header 3243: my $loc = $res->header('Location'); 3244: last unless defined $loc; 3245: 3246: # Resolve relative Location URLs against the current base 3247: if ($loc !~ m{^https?://}i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3241_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3248: require URI; 3249: $loc = URI->new_abs($loc, $current)->as_string(); 3250: } 3251: $final = $loc; 3252: $current = $loc; 3253: } elsif ($res->is_success()) { 3254: # 2xx: inspect body for client-side redirect patterns 3255: my $body = $res->decoded_content() // ''; 3256: my $dest; 3257: 3258: # <meta http-equiv="refresh" content="N; url=https://..."> 3259: if ($body =~ m{<meta[^>]+http-equiv\s*=\s*["']?refresh["']?[^>]+Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3247_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3260: content\s*=\s*["'][^"']*url\s*=\s*(https?://[^"'\s>]+)}xi) { 3261: $dest = $1; 3262: } 3263: # window.location.replace("...") or window.location.href = "..." 3264: elsif ($body =~ m{window\.location(?:\.replace\s*\(\s*|\.href\s*=\s*) 3265: ["'](https?://[^"']+)["']}xi) { 3266: $dest = $1; 3267: } 3268: 3269: last unless defined $dest; 3270: $final = $dest; 3271: $current = $dest; 3272: } else { 3273: last; 3274: } 3275: } 3276: 3277: # Cache for reuse across messages in this session 3278: $_cache->set("redirect:$url", $final) if $_cache && defined $final; 3279: 3280: return $final;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3259_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3281: } 3282: 3283: # _parallel_resolve_hosts( \%hostnames, \%cache ) 3284: # 3285: # Purpose: 3286: # Resolve multiple hostnames to IPs in parallel using AnyEvent::DNS. 3287: # Populates the cache with resolved IPs so the sequential loop in 3288: # _extract_and_resolve_urls() can skip the DNS step for pre-resolved hosts. 3289: # 3290: # Entry criteria: 3291: # $hostnames_ref -- hashref keyed by hostname (values ignored). 3292: # $cache_ref -- hashref to populate with { ip => '...' } results. 3293: # AnyEvent::DNS must be installed ($HAS_ANYEVENT_DNS is true). 3294: # 3295: # Exit status: 3296: # Returns undef; all results written to %$cache_ref via side effects. 3297: # 3298: # Notes: 3299: # Errors (NXDOMAIN, timeout) are silently swallowed; the sequential 3300: # resolution loop will return '(unresolved)' for those hosts. 3301: 3302: sub _parallel_resolve_hosts :Private { ●3303 → 3317 → 3334 3303: my ($self, $hostnames_ref, $cache_ref) = @_; 3304: # Guard both conditions together: an empty hash must never reach condvar 3305: # creation because $cv->recv would block forever with $pending == 0. 3306: return unless $HAS_ANYEVENT_DNS && %$hostnames_ref; 3307: 3308: # Build an AnyEvent condvar to wait for all lookups to complete 3309: my $cv = AnyEvent->condvar; 3310: my $pending = scalar keys %$hostnames_ref; 3311: 3312: # AnyEvent::DNS::resolve is a method, not a standalone function. 3313: # Use the global resolver singleton; passing a bare string as the 3314: # first arg would make Perl treat the hostname as the invocant. 3315: my $resolver = AnyEvent::DNS::resolver(); 3316: 3317: for my $host (keys %$hostnames_ref) { 3318: # Fire an async A query for each hostname 3319: $resolver->resolve( 3320: $host, 'a', 3321: sub { 3322: my @answers = @_; 3323: if (@answers) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3280_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3280_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3324: # Cache the first A record result 3325: $cache_ref->{$host} = { ip => $answers[0][4] }; 3326: } 3327: # Decrement the pending counter; signal when all done 3328: $cv->send if --$pending <= 0;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3323_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3329: }, 3330: ); 3331: } 3332: 3333: # Block until all DNS queries complete (subject to AnyEvent's own timeouts) 3334: $cv->recv; 3335: } 3336: 3337: # _extract_http_urls( $body ) -> list of url strings 3338: # 3339: # Purpose: 3340: # Extract all HTTP and HTTPS URLs from a body string, using both 3341: # structural HTML parsing (if HTML::LinkExtor is available) and a 3342: # plain-text regex pass. Deduplicates and strips trailing punctuation. 3343: # 3344: # Entry criteria: 3345: # $body -- combined plain+HTML body string. 3346: # 3347: # Exit status: 3348: # Returns a list of URL strings (possibly empty), deduplicated. 3349: 3350: sub _extract_http_urls :Private { ●3351 → 3355 → 3372 3351: my ($self, $body) = @_; 3352: my @urls; 3353: 3354: # Structural HTML link extraction (handles quoted attributes correctly) 3355: if ($HAS_HTML_LINKEXTOR) {Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_3328_29_<: Numeric boundary flip <= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3328_29_>: Numeric boundary flip <= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3328_29_>=: Numeric boundary flip <= to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );3356: my $p = HTML::LinkExtor->new(sub { 3357: my ($tag, %attrs) = @_; 3358: for my $attr (qw(href src action)) { 3359: my $val = $attrs{$attr} // ''; 3360: if ($val =~ m{^https?://}i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3355_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3361: push @urls, $val; 3362: } elsif ($val =~ m{^//[\w.-]}) { 3363: # Protocol-relative -- assume https 3364: push @urls, 'https:' . $val; 3365: } 3366: } 3367: }); 3368: $p->parse($body); 3369: } 3370: 3371: # Plain-text regex pass for bare URLs not in HTML attributes ●3372 → 3372 → 3377 3372: while ($body =~ m{(https?://[^\s<>"'\)\]]+)}gi) { 3373: push @urls, $1; 3374: } 3375: 3376: # Protocol-relative URLs not caught above ●3377 → 3377 → 3382 3377: while ($body =~ m{(?:^|[\s"'=])(//[\w.-][^\s<>"'\)\]]*)}gim) { 3378: push @urls, 'https:' . $1; 3379: } 3380: 3381: # Deduplicate and strip trailing punctuation 3382: my %seen; 3383: my @all = grep { !$seen{$_}++ } @urls; 3384: s/[.,;:!?\)>\]]+$// for @all; 3385: return @all;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3360_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3386: } 3387: 3388: # ----------------------------------------------------------------------- 3389: # Private: domain extraction and full analysis 3390: # ----------------------------------------------------------------------- 3391: 3392: # _extract_and_analyse_domains() -> arrayref of domain hashrefs 3393: # 3394: # Purpose: 3395: # Collect all non-infrastructure contact domains from headers and body, 3396: # run the full domain intelligence pipeline on each, and return an arrayref 3397: # suitable for storage in $self->{_mailto_domains}. 3398: # 3399: # Entry criteria: 3400: # _split_message() must have been called. 3401: # 3402: # Exit status: 3403: # Always returns an arrayref; never undef. 3404: # 3405: # Side effects: 3406: # Network I/O per domain via _analyse_domain(). 3407: # Results stored in $self->{_domain_info} and CHI cache. 3408: 3409: sub _extract_and_analyse_domains :Private { ●3410 → 3415 → 3425 3410: my $self = $_[0]; 3411: my (%seen, @domains_with_source); 3412: 3413: # Build a set of recipient domains to exclude (victims, not senders) 3414: my %recipient_domains; 3415: for my $hname (qw(to cc)) { 3416: my $val = $self->_header_value($hname) // next; 3417: for my $dom ($self->_domains_from_text($val)) { 3418: my $reg = _registrable($dom) // $dom; 3419: $recipient_domains{$dom}++; 3420: $recipient_domains{$reg}++; 3421: } 3422: } 3423: 3424: # Also exclude domains from Received: "for" envelope recipients ●3425 → 3425 → 3434 3425: for my $hop (@{ $self->{_rcvd_tracking} }) { 3426: next unless $hop->{for} && $hop->{for} =~ /\@([\w.-]+)/; 3427: my $dom = lc $1; 3428: my $reg = _registrable($dom) // $dom; 3429: $recipient_domains{$dom}++; 3430: $recipient_domains{$reg}++; 3431: } 3432: 3433: # Inner closure: record a domain if it passes all filters ●3434 → 3456 → 3463 3434: my $record = sub { 3435: my ($dom, $source) = @_; 3436: $dom = lc $dom; 3437: $dom =~ s/\.$//; 3438: next if $self->{trusted_domains}->{$dom}; 3439: return if $TRUSTED_DOMAINS{$dom}; 3440: return if $recipient_domains{$dom}; 3441: return if $recipient_domains{ _registrable($dom) // $dom }; 3442: # Discard non-routable hostnames (single-label, pseudo-TLDs, etc.) 3443: return unless $dom =~ /\.[a-zA-Z]{2,}$/; 3444: return if $dom =~ /\.(?:local|internal|lan|localdomain|arpa)$/i; 3445: return if $seen{$dom}++; 3446: push @domains_with_source, { domain => $dom, source => $source }; 3447: }; 3448: 3449: # Collect from standard sender/reply headers 3450: my %header_sources = ( 3451: 'from' => 'From: header', 3452: 'reply-to' => 'Reply-To: header', 3453: 'return-path' => 'Return-Path: header', 3454: 'sender' => 'Sender: header', 3455: ); 3456: for my $hname (sort keys %header_sources) { 3457: my $val = $self->_header_value($hname) // next; 3458: $record->($_, $header_sources{$hname}) 3459: for $self->_domains_from_text($val); 3460: } 3461: 3462: # Message-ID domain often reveals the real bulk-sending platform ●3463 → 3464 → 3472 3463: my $mid = $self->_header_value('message-id'); 3464: if ($mid && $mid =~ /\@([\w.-]+)/) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3385_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3385_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3465: my $mid_dom = lc $1; 3466: my $mid_reg = _registrable($mid_dom) // $mid_dom; 3467: $record->($mid_dom, 'Message-ID: header') 3468: unless $TRUSTED_DOMAINS{$mid_dom} || $TRUSTED_DOMAINS{$mid_reg} || $self->{trusted_domains}->{$mid_dom} || $self->{trusted_domains}->{$mid_reg}; 3469: } 3470: 3471: # DKIM signing domain(s) -- the organisation that vouches for the message ●3472 → 3473 → 3478 3472: my $auth = $self->_parse_auth_results_cached(); 3473: for my $dkim_d (@{ $auth->{dkim_domains} // [] }) { 3474: $record->($dkim_d, 'DKIM-Signature: d= (signing domain)'); 3475: } 3476: 3477: # List-Unsubscribe identifies the ESP or bulk sender ●3478 → 3479 → 3489 3478: my $unsub = $self->_header_value('list-unsubscribe'); 3479: if ($unsub) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3464_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3480: while ($unsub =~ m{https?://([^/:?\s>]+)}gi) { 3481: $record->(lc $1, 'List-Unsubscribe: header'); 3482: } 3483: while ($unsub =~ m{mailto:[^@\s>]+\@([\w.-]+)}gi) { 3484: $record->(lc $1, 'List-Unsubscribe: header'); 3485: } 3486: } 3487: 3488: # Body email addresses (mailto: and bare user@domain forms) ●3489 → 3495 → 3499 3489: my $combined = $self->{_body_plain} . "\n" . $self->{_body_html}; 3490: $record->($_, 'email address / mailto in body') 3491: for $self->_domains_from_text($combined); 3492: 3493: # Run the full intelligence pipeline on each collected domain 3494: my @results; 3495: for my $entry (@domains_with_source) { 3496: my $info = $self->_analyse_domain($entry->{domain}); 3497: push @results, { %$entry, %$info }; 3498: } 3499: return \@results;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3479_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3500: } 3501: 3502: # _domains_from_text( $text ) -> list of domain strings 3503: # 3504: # Purpose: 3505: # Extract unique domain names from mailto: links and bare user@domain 3506: # addresses in a block of text. 3507: # 3508: # Entry criteria: 3509: # $text -- a defined scalar of decoded body or header text. 3510: # 3511: # Exit status: 3512: # Returns a list of lower-cased domain strings (possibly empty). 3513: 3514: sub _domains_from_text :Private { ●3515 → 3519 → 3525 3515: my ($self, $text) = @_; 3516: my (%seen, @out); 3517: 3518: # mailto: links (including HTML-entity-encoded @ signs from QP) 3519: while ($text =~ /mailto:(?:[^@\s<>"]+)@([\w.-]+)/gi) { 3520: my $dom = lc $1; $dom =~ s/\.$//; 3521: push @out, $dom unless $seen{$dom}++; 3522: } 3523: 3524: # Bare user@domain patterns ●3525 → 3525 → 3529 3525: while ($text =~ /\b[\w.+%-]+@([\w.-]+\.[a-zA-Z]{2,})\b/g) { 3526: my $dom = lc $1; $dom =~ s/\.$//; 3527: push @out, $dom unless $seen{$dom}++; 3528: } 3529: return @out;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3499_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3499_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3530: } 3531: 3532: # _analyse_domain( $domain ) -> hashref 3533: # 3534: # Purpose: 3535: # Run the complete intelligence pipeline for a single domain: A record 3536: # (web hosting), MX record (mail hosting), NS record (DNS hosting), 3537: # and WHOIS (registrar, creation/expiry dates, abuse contact). 3538: # Each IP is enriched via RDAP/WHOIS. Results are cached per domain 3539: # in $self->{_domain_info} and in the CHI cross-message cache. 3540: # 3541: # Entry criteria: 3542: # $domain -- lower-cased, no trailing dot, not in TRUSTED_DOMAINS. 3543: # $self->{timeout} used for all network operations. 3544: # 3545: # Exit status: 3546: # Always returns a hashref reference; never undef; may be empty ({}). 3547: # Possible keys: web_ip, web_org, web_abuse, mx_host, mx_ip, mx_org, 3548: # mx_abuse, ns_host, ns_ip, ns_org, ns_abuse, registrar, 3549: # registrar_abuse, registered, expires, recently_registered, whois_raw. 3550: # 3551: # Side effects: 3552: # Network I/O; writes result to $self->{_domain_info}{$domain} and CHI. 3553: # 3554: # Notes: 3555: # MX/NS lookups require Net::DNS; absent without it. 3556: # recently_registered is set to 1 (not 0) when the threshold is met. 3557: # whois_raw is truncated to WHOIS_RAW_MAX bytes. 3558: 3559: sub _analyse_domain :Private { ●3560 → 3567 → 3575 3560: my ($self, $domain) = @_; 3561: 3562: # Return the per-message cached result if already analysed 3563: return $self->{_domain_info}{$domain}Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3529_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3529_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3564: if $self->{_domain_info}{$domain}; 3565: 3566: # Check the cross-message CHI cache before hitting the network 3567: if ($_cache) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3563_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3563_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3568: my $cached = $_cache->get("dom:$domain"); 3569: if ($cached) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3567_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3570: $self->{_domain_info}{$domain} = $cached; 3571: return $cached;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3569_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3572: } 3573: } 3574: ●3575 → 3580 → 3588 3575: $self->_debug("Analysing domain: $domain"); 3576: my %info; 3577: 3578: # --- A record -> web hosting IP --- 3579: my $web_ip = $self->_resolve_host($domain); 3580: if ($web_ip) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3571_4: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3571_4: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3581: $info{web_ip} = $web_ip; 3582: my $w = $self->_whois_ip($web_ip); 3583: $info{web_org} = $w->{org} if $w->{org}; 3584: $info{web_abuse} = $w->{abuse} if $w->{abuse}; 3585: } 3586: 3587: # MX and NS lookups require Net::DNS ●3588 → 3588 → 3631 3588: if ($HAS_NET_DNS) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3580_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3589: my $res = Net::DNS::Resolver->new( 3590: tcp_timeout => $self->{timeout}, 3591: udp_timeout => $self->{timeout}, 3592: ); 3593: 3594: # --- MX record -> mail hosting --- 3595: if(my $mxq = $res->search($domain, 'MX')) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3588_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3596: my ($best) = sort { $a->preference <=> $b->preference } 3597: grep { $_->type eq 'MX' } $mxq->answer; 3598: if ($best) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3595_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3599: (my $mx_host = lc $best->exchange) =~ s/\.$//; 3600: $info{mx_host} = $mx_host; 3601: my $mx_ip = $self->_resolve_host($mx_host); 3602: if ($mx_ip) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3598_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3603: $info{mx_ip} = $mx_ip; 3604: my $mw = $self->_whois_ip($mx_ip); 3605: $info{mx_org} = $mw->{org} if $mw->{org}; 3606: $info{mx_abuse} = $mw->{abuse} if $mw->{abuse}; 3607: } 3608: } 3609: } 3610: 3611: # --- NS record -> DNS hosting --- 3612: if(my $nsq = $res->search($domain, 'NS')) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3602_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3613: # Sort alphabetically so DNS round-robin ordering never changes which 3614: # nameserver we record -- both runs of the same domain always agree. 3615: my ($first) = sort { $a->nsdname cmp $b->nsdname } grep { $_->type eq 'NS' } $nsq->answer; 3616: if ($first) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3612_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3617: (my $ns_host = lc $first->nsdname) =~ s/\.$//; 3618: $info{ns_host} = $ns_host; 3619: my $ns_ip = $self->_resolve_host($ns_host); 3620: if ($ns_ip) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3616_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3621: $info{ns_ip} = $ns_ip; 3622: my $nw = $self->_whois_ip($ns_ip); 3623: $info{ns_org} = $nw->{org} if $nw->{org}; 3624: $info{ns_abuse} = $nw->{abuse} if $nw->{abuse}; 3625: } 3626: } 3627: } 3628: } 3629: 3630: # --- Domain WHOIS -> registrar + dates --- ●3631 → 3631 → 3683 3631: if(my $domain_whois = $self->_domain_whois($domain)) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3620_5: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3632: # Truncate raw WHOIS for storage but parse structured fields from full text 3633: $info{whois_raw} = substr($domain_whois, 0, $WHOIS_RAW_MAX); 3634: 3635: # Registrar name 3636: if ($domain_whois =~ /Registrar:\s*(.+)/i) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3631_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3637: ($info{registrar} = $1) =~ s/\s+$//; 3638: } 3639: 3640: # Registrar abuse contact email (try multiple field names) 3641: for my $pat ( 3642: qr/Registrar Abuse Contact Email:\s*(\S+@\S+)/i, 3643: qr/Abuse Contact Email:\s*(\S+@\S+)/i, 3644: qr/abuse-contact:\s*(\S+@\S+)/i, 3645: ) { 3646: if (!$info{registrar_abuse} && $domain_whois =~ $pat) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3636_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3647: ($info{registrar_abuse} = $1) =~ s/\s+$//; 3648: } 3649: } 3650: 3651: # Domain creation date (multiple registrar field name variations) 3652: for my $pat ( 3653: qr/Creation Date:\s*(\S+)/i, 3654: qr/Created(?:\s+On)?:\s*(\S+)/i, 3655: qr/Registration Time:\s*(\S+)/i, 3656: qr/^registered:\s*(\S+)/im, 3657: ) { 3658: if (!$info{registered} && $domain_whois =~ $pat) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3646_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3659: ($info{registered} = $1) =~ s/[TZ].*//; 3660: } 3661: } 3662: 3663: # Domain expiry date 3664: for my $pat ( 3665: qr/Registry Expiry Date:\s*(\S+)/i, 3666: qr/Expir(?:y|ation)(?: Date)?:\s*(\S+)/i, 3667: qr/paid-till:\s*(\S+)/i, 3668: ) { 3669: if (!$info{expires} && $domain_whois =~ $pat) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3658_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3670: ($info{expires} = $1) =~ s/[TZ].*//; 3671: } 3672: } 3673: 3674: # Flag recently-registered domains (< RECENT_REG_DAYS old) 3675: if ($info{registered}) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3669_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3676: my $epoch = $self->_parse_date_to_epoch($info{registered}); 3677: $info{recently_registered} = 1 3678: if $epoch && (time() - $epoch) < $RECENT_REG_DAYS * $SECS_PER_DAY;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3675_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3679: } 3680: } 3681: 3682: # Store in per-message and cross-message caches 3683: $self->{_domain_info}{$domain} = \%info; 3684: $_cache->set("dom:$domain", \%info) if $_cache; 3685: 3686: return \%info;Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_3678_36_>: Numeric boundary flip < to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3678_36_<=: Numeric boundary flip < to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_3678_36_>=: Numeric boundary flip < to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );3687: } 3688: 3689: # ----------------------------------------------------------------------- 3690: # Private: DNS helpers 3691: # ----------------------------------------------------------------------- 3692: 3693: # _resolve_host( $host ) -> ip_string | undef 3694: # 3695: # Purpose: 3696: # Resolve a hostname to an IPv4 (or IPv6) address. Uses Net::DNS for 3697: # both A and AAAA queries when available; falls back to inet_aton for 3698: # pure IPv4 resolution. 3699: # 3700: # Entry criteria: 3701: # $host -- hostname string or already-numeric IP. 3702: # 3703: # Exit status: 3704: # Returns the first resolved IP string, or undef on failure. 3705: # 3706: # Notes: 3707: # When the input is already a dotted-quad IPv4 it is returned immediately. 3708: # AAAA records are tried if the A query fails and Net::DNS is available. 3709: 3710: sub _resolve_host :Protected { ●3711 → 3715 → 3720 3711: my ($self, $host) = @_; 3712: return $host if $host =~ /^\d{1,3}(?:\.\d{1,3}){3}$/;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3686_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3686_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3713: 3714: # Check the CHI cache before hitting DNS 3715: if ($_cache) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3712_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3712_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3716: my $cached_ip = $_cache->get("resolve:$host"); 3717: return $cached_ip if defined $cached_ip;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3715_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3718: } 3719: ●3720 → 3722 → 3751 3720: my $ip; 3721: 3722: if ($HAS_NET_DNS) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3717_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3717_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3723: my $res = Net::DNS::Resolver->new( 3724: tcp_timeout => $self->{timeout}, 3725: udp_timeout => $self->{timeout}, 3726: ); 3727: 3728: # Try A record first, then AAAA for IPv6 3729: for my $type (qw(A AAAA)) { 3730: my $query = $res->search($host, $type); 3731: if ($query) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3722_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3732: for my $rr ($query->answer) { 3733: if ($rr->type eq 'A') {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3731_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3734: $ip = $rr->address; 3735: last; 3736: } elsif ($rr->type eq 'AAAA') { 3737: $ip = $rr->address; 3738: last; 3739: } 3740: } 3741: } 3742: last if defined $ip; 3743: } 3744: } else { 3745: # Fallback: gethostbyname (IPv4 only) 3746: my $packed = eval { inet_aton($host) }; 3747: $ip = $packed ? inet_ntoa($packed) : undef; 3748: } 3749: 3750: # Cache the result (including undef as '' to avoid repeated failed lookups) ●3751 → 3751 → 3755 3751: if ($_cache) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3733_6: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3752: $_cache->set("resolve:$host", $ip // ''); 3753: } 3754: 3755: return $ip;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3751_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3756: } 3757: 3758: # _reverse_dns( $ip ) -> hostname | undef 3759: # 3760: # Purpose: 3761: # Perform a PTR (reverse DNS) lookup for an IP address. Supports both 3762: # IPv4 and IPv6 via Net::DNS when available; falls back to gethostbyaddr. 3763: # 3764: # Entry criteria: 3765: # $ip -- a defined IPv4 or IPv6 address string. 3766: # 3767: # Exit status: 3768: # Returns the PTR hostname string, or undef if no record exists. 3769: 3770: sub _reverse_dns :Protected { ●3771 → 3774 → 3786 3771: my ($self, $ip) = @_; 3772: return unless $ip; 3773: 3774: if ($HAS_NET_DNS) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3755_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3755_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3775: my $res = Net::DNS::Resolver->new(tcp_timeout => $self->{timeout}); 3776: my $query = $res->search($ip, 'PTR'); 3777: if ($query) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3774_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3778: for my $rr ($query->answer) { 3779: return $rr->ptrdname if $rr->type eq 'PTR';Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3777_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3780: } 3781: } 3782: return; 3783: } 3784: 3785: # Fallback for IPv4 only 3786: return scalar gethostbyaddr(inet_aton($ip), AF_INET);Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3779_5: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3779_5: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3787: } 3788: 3789: # ----------------------------------------------------------------------- 3790: # Private: WHOIS / RDAP 3791: # ----------------------------------------------------------------------- 3792: 3793: # _whois_ip( $ip ) -> hashref 3794: # 3795: # Purpose: 3796: # Enrich an IP address with organisation name, abuse contact, and country 3797: # code. Tries RDAP first (if LWP is available), then falls back to raw 3798: # WHOIS via IANA referral. Results are cached in CHI if available. 3799: # 3800: # Entry criteria: 3801: # $ip -- a defined IPv4 or IPv6 address string. 3802: # 3803: # Exit status: 3804: # Returns { org, abuse, country } hashref; keys absent when unknown. 3805: 3806: sub _whois_ip :Protected { ●3807 → 3810 → 3815 3807: my ($self, $ip) = @_; 3808: 3809: # Check CHI cache before going to the network 3810: if ($_cache) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3786_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3786_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3811: my $cached = $_cache->get("whois_ip:$ip"); 3812: return $cached if $cached;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3810_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3813: } 3814: ●3815 → 3818 → 3828 3815: my $result = $HAS_LWP ? $self->_rdap_lookup($ip) : {}; 3816: 3817: # Fall back to raw WHOIS if RDAP returned no organisation 3818: unless ($result->{org}) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3812_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3812_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3819: my $raw = $self->_raw_whois($ip, 'whois.iana.org'); 3820: if ($raw) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3818_2: Invert condition unless to if
MEDIUM: Add tests asserting both true and false outcomes3821: my ($ref) = $raw =~ /whois:\s*([\w.-]+)/i; 3822: my $detail = $ref ? $self->_raw_whois($ip, $ref) : $raw; 3823: $result = $self->_parse_whois_text($detail) if $detail; 3824: } 3825: } 3826: 3827: # Cache the enrichment result 3828: $_cache->set("whois_ip:$ip", $result) if $_cache && $result; 3829: 3830: return $result;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3820_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3831: } 3832: 3833: # _domain_whois( $domain ) -> raw_whois_string | undef 3834: # 3835: # Purpose: 3836: # Perform a two-step WHOIS lookup for a domain: first ask IANA for the 3837: # TLD's authoritative WHOIS server, then query that server. 3838: # 3839: # Entry criteria: 3840: # $domain -- a lower-cased domain name string. 3841: # 3842: # Exit status: 3843: # Returns the raw WHOIS response string, or undef on failure. 3844: 3845: sub _domain_whois :Protected { 3846: my ($self, $domain) = @_; 3847: my $iana = $self->_raw_whois($domain, 'whois.iana.org') // return; 3848: my ($server) = $iana =~ /whois:\s*([\w.-]+)/i; 3849: return unless $server; 3850: return $self->_raw_whois($domain, $server);Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3830_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3830_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3851: } 3852: 3853: # _parse_domain_whois_abuse( $domain ) -> hashref 3854: # 3855: # Purpose: 3856: # Lightweight domain WHOIS lookup to extract only registrar name and 3857: # abuse contact. Used as a fallback in _extract_and_resolve_urls() when 3858: # a URL host cannot be resolved to an IP. 3859: # 3860: # Entry criteria: 3861: # $domain -- a registrable domain name string. 3862: # 3863: # Exit status: 3864: # Returns { org, abuse } hashref; empty hashref on failure. 3865: 3866: sub _parse_domain_whois_abuse :Private { ●3867 → 3870 → 3874 3867: my ($self, $domain) = @_; 3868: my $raw = $self->_domain_whois($domain) // return {}; 3869: my %info; 3870: if ($raw =~ /Registrar:\s*(.+)/i) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3850_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3850_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3871: ($info{org} = $1) =~ s/\s+$//; 3872: } 3873: # Try multiple field name patterns for the abuse email ●3874 → 3874 → 3883 3874: for my $pat ( 3875: qr/Registrar Abuse Contact Email:\s*(\S+\@\S+)/i, 3876: qr/Abuse Contact Email:\s*(\S+\@\S+)/i, 3877: qr/abuse-contact:\s*(\S+\@\S+)/i, 3878: ) { 3879: if (!$info{abuse} && $raw =~ $pat) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3870_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3880: ($info{abuse} = $1) =~ s/\s+$//; 3881: } 3882: } 3883: return \%info;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3879_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3884: } 3885: 3886: # _rdap_lookup( $ip ) -> hashref 3887: # 3888: # Purpose: 3889: # Query the ARIN RDAP API for IP block ownership information. RDAP is 3890: # preferred over raw WHOIS because it returns structured JSON. 3891: # 3892: # Entry criteria: 3893: # $ip -- a defined IPv4 or IPv6 address string. 3894: # LWP::UserAgent must be installed. 3895: # 3896: # Exit status: 3897: # Returns { org, abuse, country } hashref; empty hashref on failure. 3898: 3899: sub _rdap_lookup :Protected { ●[NOT COVERED] 3900 → 3904 → 3922 3900: my ($self, $ip) = @_; 3901: return {} unless $HAS_LWP; 3902: 3903: my $ua = $self->{ua}; 3904: if(!defined($ua)) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3883_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3883_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );3905: $ua = LWP::UserAgent->new( 3906: timeout => $self->{timeout}, 3907: agent => "Email-Abuse-Investigator/$VERSION", 3908: ); 3909: 3910: if($HAS_CONN_CACHE) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3904_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3911: my $conn_cache = LWP::ConnCache->new(); 3912: $conn_cache->total_capacity(10); 3913: $ua->conn_cache($conn_cache); 3914: } 3915: 3916: $ua->env_proxy(1); 3917: $self->{ua} = $ua; 3918: } 3919: 3920: # Use the ARIN RDAP endpoint; it covers the ARIN region and redirects 3921: # for RIPE/APNIC/LACNIC/AfriNIC allocations. ●[NOT COVERED] 3922 → 3929 → 3930 3922: my $res = eval { $ua->get("https://rdap.arin.net/registry/ip/$ip") }; 3923: return {} unless $res && $res->is_success(); 3924: 3925: my $j = $res->decoded_content(); 3926: my %info; 3927: 3928: # Extract organisation name from the JSON response 3929: if ($j =~ /"name"\s*:\s*"([^"]+)"/) { $info{org} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3910_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes●[NOT COVERED] 3930 → 3930 → 3933 3930: if ($j =~ /"handle"\s*:\s*"([^"]+)"/) { $info{handle} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3929_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3931: 3932: # Extract abuse email from the vcardArray contact block ●[NOT COVERED] 3933 → 3933 → 3940 3933: if ($j =~ /"abuse".*?"email"\s*:\s*"([^"]+)"/s) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3930_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3934: $info{abuse} = $1; 3935: } elsif ($j =~ /"email"\s*:\s*"([^@"]+@[^"]+)"/) { 3936: $info{abuse} = $1; 3937: } 3938: 3939: # Country code from the network's country field ●[NOT COVERED] 3940 → 3940 → 3942 3940: if ($j =~ /"country"\s*:\s*"([A-Z]{2})"/) { $info{country} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3933_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3941: 3942: return \%info;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_3940_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes3943: } 3944: 3945: # _raw_whois( $query, $server ) -> string | undef 3946: # 3947: # Purpose: 3948: # Open a TCP connection to a WHOIS server on port 43, send the query, 3949: # and return the full response as a string. Uses IO::Select for read 3950: # timeouts so that alarm() is never needed (alarm() is unreliable on 3951: # Windows and in threaded Perl). Supports IPv6 WHOIS servers via 3952: # IO::Socket::IP when that module is available. 3953: # 3954: # Entry criteria: 3955: # $query -- the domain name or IP to query (defined, non-empty). 3956: # $server -- the WHOIS server hostname (default: 'whois.iana.org'). 3957: # $self->{timeout} -- seconds used for connect and per-read waits. 3958: # 3959: # Exit status: 3960: # Returns the raw WHOIS response string, or undef on connection/write failure. 3961: # 3962: # Notes: 3963: # Uses IO::Socket::IP (dual-stack) when available, falling back to 3964: # IO::Socket::INET (IPv4 only) otherwise. The IO::Select loop reads 3965: # until the server closes the connection or the per-read timeout expires. 3966: 3967: sub _raw_whois :Protected { ●3968 → 3996 → 4008 3968: my ($self, $query, $server) = @_; 3969: $server //= 'whois.iana.org'; 3970: $self->_debug("WHOIS $server -> $query"); 3971: 3972: # Choose the socket class based on what is installed. 3973: # IO::Socket::IP supports both IPv4 and IPv6 WHOIS servers. 3974: my $sock_class = $HAS_IO_SOCKET_IP ? 'IO::Socket::IP' : 'IO::Socket::INET'; 3975: 3976: # Attempt TCP connection to port 43 on the WHOIS server 3977: my $sock = eval { 3978: $sock_class->new( 3979: PeerAddr => $server, 3980: PeerPort => $WHOIS_PORT, 3981: Proto => 'tcp', 3982: Timeout => $self->{timeout}, 3983: ); 3984: }; 3985: return unless $sock; 3986: 3987: # Send the WHOIS query in wire format (CRLF-terminated per RFC 3912) 3988: $sock->print("$query\r\n") or do { $sock->close(); return }; 3989: 3990: # Use IO::Select to implement per-read timeouts without alarm() 3991: my $sel = IO::Select->new($sock); 3992: my $response = ''; 3993: my $buf = ''; 3994: 3995: # Read until EOF (server closes) or timeout 3996: while ($sel->can_read($self->{timeout})) { 3997: # Wrap in eval to catch 'Connection reset by peer' thrown by Fatal/autodie 3998: my $n = eval { sysread($sock, $buf, $WHOIS_READ_CHUNK) }; 3999: 4000: if ($@ || !defined $n || $n <= 0) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_3942_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_3942_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4001: $self->_debug("WHOIS read failed: $@") if $@; 4002: last; 4003: } 4004: last if !defined($n) || $n <= 0;Mutants (Total: 4, Killed: 0, Survived: 4)
- NUM_BOUNDARY_4000_31_<: Numeric boundary flip <= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4000_31_>: Numeric boundary flip <= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4000_31_>=: Numeric boundary flip <= to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- COND_INV_4000_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4005: $response .= $buf; 4006: } 4007: 4008: $sock->close(); 4009: return $response || undef;Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_4004_30_<: Numeric boundary flip <= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4004_30_>: Numeric boundary flip <= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4004_30_>=: Numeric boundary flip <= to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );4010: } 4011: 4012: # _parse_whois_text( $text ) -> hashref 4013: # 4014: # Purpose: 4015: # Parse a raw WHOIS IP block response to extract organisation name, 4016: # abuse contact email, and country code. 4017: # 4018: # Entry criteria: 4019: # $text -- a defined WHOIS response string. 4020: # 4021: # Exit status: 4022: # Returns { org, abuse, country } hashref; keys absent when not found. 4023: 4024: sub _parse_whois_text :Private { ●4025 → 4030 → 4040 4025: my ($self, $text) = @_; 4026: return {} unless $text; 4027: my %info; 4028: 4029: # Try multiple field names for the organisation name 4030: for my $pat ( 4031: qr/^OrgName:\s*(.+)/mi, qr/^org-name:\s*(.+)/mi, 4032: qr/^owner:\s*(.+)/mi, qr/^descr:\s*(.+)/mi, 4033: ) { 4034: if (!$info{org} && $text =~ $pat) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4009_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4009_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4035: ($info{org} = $1) =~ s/\s+$//; 4036: } 4037: } 4038: 4039: # Try multiple field names for the abuse email ●4040 → 4040 → 4050 4040: for my $pat ( 4041: qr/OrgAbuseEmail:\s*(\S+@\S+)/mi, 4042: qr/abuse-mailbox:\s*(\S+@\S+)/mi, 4043: ) { 4044: if (!$info{abuse} && $text =~ $pat) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4034_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4045: ($info{abuse} = $1) =~ s/\s+$//; 4046: } 4047: } 4048: 4049: # Last-resort: any abuse@ address in the response ●4050 → 4050 → 4053 4050: if (!$info{abuse} && $text =~ /(abuse\@[\w.-]+)/i) { $info{abuse} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4044_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4051: 4052: # Country code (case-insensitive match, normalised to uppercase) ●4053 → 4053 → 4056 4053: if ($text =~ /^country:\s*([A-Za-z]{2})\s*$/m) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4050_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4054: $info{country} = uc $1; 4055: } 4056: return \%info;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4053_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4057: } 4058: 4059: # ----------------------------------------------------------------------- 4060: # Private: authentication results parsing 4061: # ----------------------------------------------------------------------- 4062: 4063: # _parse_auth_results_cached() -> hashref 4064: # 4065: # Purpose: 4066: # Parse the Authentication-Results: header(s) from the message once, 4067: # cache the result, and return it. Extracts SPF, DKIM, DMARC, ARC 4068: # results and the DKIM signing domain(s). 4069: # 4070: # Entry criteria: 4071: # $self->{_headers} populated by _split_message(). 4072: # 4073: # Exit status: 4074: # Returns { spf, dkim, dmarc, arc, dkim_domain, dkim_domains } hashref. 4075: # Keys absent when the corresponding header or field is not present. 4076: 4077: sub _parse_auth_results_cached :Private { ●4078 → 4091 → 4092 4078: my $self = $_[0]; 4079: return $self->{_auth_results} if $self->{_auth_results};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4056_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4056_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4080: 4081: my %auth; 4082: 4083: # Concatenate all Authentication-Results: header values 4084: my $raw = join('; ', 4085: map { $_->{value} } 4086: grep { $_->{name} eq 'authentication-results' } 4087: @{ $self->{_headers} } 4088: ); 4089: 4090: # Extract individual authentication mechanism results 4091: if ($raw =~ /\bspf=(\S+)/i) { $auth{spf} = $1 }Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4079_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4079_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );●4092 → 4092 → 4093 4092: if ($raw =~ /\bdkim=(\S+)/i) { $auth{dkim} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4091_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes●4093 → 4093 → 4094 4093: if ($raw =~ /\bdmarc=(\S+)/i) { $auth{dmarc} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4092_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes●4094 → 4094 → 4097 4094: if ($raw =~ /\barc=(\S+)/i) { $auth{arc} = $1 }Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4093_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4095: 4096: # Strip trailing punctuation captured by the greedy \S+ ●4097 → 4097 → 4103 4097: for my $k (qw(spf dkim dmarc arc)) { 4098: $auth{$k} =~ s/[;,\s]+$// if defined $auth{$k}; 4099: } 4100: 4101: # Extract DKIM signing domains from all DKIM-Signature: d= tags. 4102: # Prefer the first domain that matches the provider table (identifies ESP). ●4103 → 4104 → 4110 4103: my @dkim_domains; 4104: for my $h (grep { $_->{name} eq 'dkim-signature' } @{ $self->{_headers} }) { 4105: if ($h->{value} =~ /\bd=([^;,\s]+)/) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4094_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4106: push @dkim_domains, lc $1; 4107: } 4108: } 4109: ●4110 → 4110 → 4123 4110: if (@dkim_domains) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4105_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4111: # Check if any signing domain matches a known provider 4112: my $preferred; 4113: for my $d (@dkim_domains) { 4114: if ($self->_provider_abuse_for_host($d)) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4110_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4115: $preferred = $d; 4116: last; 4117: } 4118: } 4119: $auth{dkim_domain} = $preferred // $dkim_domains[0]; 4120: $auth{dkim_domains} = \@dkim_domains; 4121: } 4122: 4123: $self->{_auth_results} = \%auth; 4124: return \%auth;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4114_4: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4125: } 4126: 4127: # ----------------------------------------------------------------------- 4128: # Private: provider-table lookups 4129: # ----------------------------------------------------------------------- 4130: 4131: # _provider_abuse_for_host( $host ) -> hashref | undef 4132: # 4133: # Purpose: 4134: # Look up a hostname (and each of its parent domains, stripping one label 4135: # at a time from the left) in the %PROVIDER_ABUSE table. 4136: # 4137: # Entry criteria: 4138: # $host -- a defined hostname or domain string. 4139: # 4140: # Exit status: 4141: # Returns the %PROVIDER_ABUSE entry hashref on match, undef otherwise. 4142: 4143: sub _provider_abuse_for_host :Private { ●4144 → 4147 → 4152 4144: my ($self, $host) = @_; 4145: $host = lc $host; 4146: # Strip successive subdomains until we find a match or exhaust labels 4147: while ($host =~ /\./) { 4148: return $self->{provider_abuse}->{$host} if $self->{provider_abuse}->{$host};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4124_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4124_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4149: return $PROVIDER_ABUSE{$host} if $PROVIDER_ABUSE{$host};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4148_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4148_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4150: $host =~ s/^[^.]+\.//; 4151: } 4152: return; 4153: } 4154: 4155: # _provider_abuse_for_ip( $ip, $rdns ) -> hashref | undef 4156: # 4157: # Purpose: 4158: # Look up an IP's reverse-DNS hostname in the %PROVIDER_ABUSE table to 4159: # identify well-known provider networks by rDNS pattern. 4160: # 4161: # Entry criteria: 4162: # $ip -- IPv4 or IPv6 address string (used as fallback if $rdns absent). 4163: # $rdns -- optional rDNS hostname string. 4164: # 4165: # Exit status: 4166: # Returns the %PROVIDER_ABUSE entry on match, undef otherwise. 4167: 4168: sub _provider_abuse_for_ip :Private { 4169: my ($self, $ip, $rdns) = @_; 4170: return $self->_provider_abuse_for_host($rdns) if $rdns;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4149_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4149_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4171: return; 4172: } 4173: 4174: # ----------------------------------------------------------------------- 4175: # Private: eTLD+1 normalisation 4176: # ----------------------------------------------------------------------- 4177: 4178: # _registrable( $host ) -> string | undef 4179: # 4180: # Purpose: 4181: # Return the registrable eTLD+1 form of a hostname. Uses 4182: # Domain::PublicSuffix when installed for accurate results; falls back 4183: # to a built-in heuristic for the common two-letter ccTLD+2 pattern. 4184: # 4185: # Entry criteria: 4186: # $host -- a hostname string (may include subdomains). 4187: # 4188: # Exit status: 4189: # Returns the registrable domain string, or undef for single-label 4190: # hostnames (e.g. 'localhost'). 4191: # 4192: # Notes: 4193: # The heuristic handles co.uk, com.au, net.jp, org.nz etc. but not 4194: # uncommon second-level delegations like ltd.uk or plc.uk. 4195: 4196: sub _registrable :Private { ●4197 → 4201 → 4208 4197: my $host = $_[0]; 4198: return unless $host && $host =~ /\./; 4199: 4200: # Use Domain::PublicSuffix for accurate PSL-based normalisation 4201: if ($HAS_PUBLIC_SUFFIX) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4170_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4170_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4202: my $psl = Domain::PublicSuffix->new(); 4203: my $root = $psl->get_root_domain(lc $host); 4204: return $root if $root;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4201_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4205: } 4206: 4207: # Built-in heuristic fallback ●4208 → 4212 → 4216 4208: my @labels = split /\./, lc $host; 4209: return $host if @labels <= 2;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4204_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4204_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4210: 4211: # Detect common ccTLD second-level patterns (e.g. co.uk, com.au) 4212: if ($labels[-1] =~ /^[a-z]{2}$/ &&Mutants (Total: 5, Killed: 0, Survived: 5)
- NUM_BOUNDARY_4209_26_<: Numeric boundary flip <= to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4209_26_>: Numeric boundary flip <= to >
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4209_26_>=: Numeric boundary flip <= to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- BOOL_NEGATE_4209_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4209_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4213: $labels[-2] =~ /^(?:co|com|net|org|gov|edu|ac|me)$/) { 4214: return join('.', @labels[-3..-1]);Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4212_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4215: } 4216: return join('.', @labels[-2..-1]);Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4214_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4214_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4217: } 4218: 4219: # ----------------------------------------------------------------------- 4220: # Private: utilities 4221: # ----------------------------------------------------------------------- 4222: 4223: # _enrich_ip( $ip, $confidence, $note ) -> origin hashref 4224: # 4225: # Purpose: 4226: # Perform rDNS and WHOIS/RDAP for a single IP and package the results 4227: # into the standard origin hashref returned by originating_ip(). 4228: # 4229: # Entry criteria: 4230: # $ip -- a defined, non-private IPv4 or IPv6 address string. 4231: # $confidence -- 'high', 'medium', or 'low'. 4232: # $note -- human-readable explanation of why this IP was chosen. 4233: # 4234: # Exit status: 4235: # Returns { ip, rdns, org, abuse, country, confidence, note } hashref. 4236: 4237: sub _enrich_ip :Private { 4238: my ($self, $ip, $confidence, $note) = @_; 4239: my $rdns = $self->_reverse_dns($ip); 4240: my $whois = $self->_whois_ip($ip); 4241: return { 4242: ip => $ip, 4243: rdns => $rdns // '(no reverse DNS)', 4244: org => $whois->{org} // '(unknown)', 4245: abuse => $whois->{abuse} // '(unknown)', 4246: country => $whois->{country} // undef, 4247: confidence => $confidence, 4248: note => $note, 4249: }; 4250: } 4251: 4252: # _header_value( $name ) -> value_string | undef 4253: # 4254: # Purpose: 4255: # Return the value of the first header matching the given lower-cased 4256: # header name. 4257: =head2 header_value( $name ) 4258: 4259: Returns the value of the first occurrence of a named header field, or 4260: C<undef> if the header is absent. The name comparison is case-insensitive. 4261: 4262: =head3 Usage 4263: 4264: my $subj = $analyser->header_value('Subject'); 4265: my $from = $analyser->header_value('From'); 4266: my $msgid = $analyser->header_value('Message-ID'); 4267: 4268: =head3 Arguments 4269: 4270: =over 4 4271: 4272: =item C<$name> (string, required) 4273: 4274: The header field name, e.g. C<'Subject'>, C<'From'>, C<'X-Mailer'>. 4275: Comparison is case-insensitive. 4276: 4277: =back 4278: 4279: =head3 Returns 4280: 4281: The raw header value string (not decoded), or C<undef> if the named header 4282: is not present. When the same header appears more than once, only the value 4283: of the first occurrence is returned. 4284: 4285: =head3 Side Effects 4286: 4287: None. Header data is pre-parsed during C<parse_email()>. 4288: 4289: =head3 Notes 4290: 4291: Header values are returned verbatim, including any MIME encoded-word sequences 4292: (C<=?charset?B/Q?...?=>). Pass the result through C<_decode_mime_words()> 4293: internally if human-readable output is needed. 4294: 4295: =head3 API Specification 4296: 4297: =head4 Input 4298: 4299: { 4300: name => { type => 'string', required => 1 }, 4301: } 4302: 4303: =head4 Output 4304: 4305: { type => [ 'string', 'undef' ] } 4306: 4307: =head3 Messages 4308: 4309: None -- returns C<undef> on a missing header, never throws. 4310: 4311: =cut 4312: 4313: sub header_value { 4314: my $self = shift; 4315: 4316: my $params = Params::Validate::Strict::validate_strict({ 4317: args => Params::Get::get_params('name', \@_) || {}, 4318: schema => { 4319: name => { 4320: 'type' => 'string', 4321: 'optional' => 0, 4322: } 4323: } 4324: }); 4325: 4326: return if((!defined($params)) || !defined($params->{name})); 4327: return if ref($params->{name}); 4328: return $self->_header_value($params->{name});Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4216_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4216_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4329: } 4330: 4331: # 4332: # _header_value( $name ) -> string | undef 4333: # 4334: # Purpose: 4335: # Internal implementation for header_value(). Walks _headers list and 4336: # returns the value of the first matching header. 4337: # 4338: # Entry criteria: 4339: # $name -- a lower-cased header name string. 4340: # $self->{_headers} populated by _split_message(). 4341: # 4342: # Exit status: 4343: # Returns the value string, or undef if the header is not present. 4344: 4345: sub _header_value :Private { ●4346 → 4347 → 4350 4346: my ($self, $name) = @_; 4347: for my $h (@{ $self->{_headers} }) { 4348: return $h->{value} if $h->{name} eq lc($name);Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4328_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4328_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4349: } 4350: return; 4351: } 4352: 4353: # _ip_in_cidr( $ip, $cidr ) -> bool 4354: # 4355: # Purpose: 4356: # Test whether an IPv4 address falls within a CIDR block or is an exact 4357: # match (when $cidr contains no '/' separator). 4358: # 4359: # Entry criteria: 4360: # $ip -- a defined dotted-quad IPv4 address string. 4361: # $cidr -- a CIDR string like '10.0.0.0/8' or an exact IP. 4362: # 4363: # Exit status: 4364: # Returns 1 (true) if the IP is within the CIDR block, 0 otherwise. 4365: 4366: sub _ip_in_cidr :Private { 4367: my ($self, $ip, $cidr) = @_; 4368: return $ip eq $cidr unless $cidr =~ m{/};Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4348_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4348_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4369: my ($net_addr, $prefix) = split m{/}, $cidr; 4370: return 0 if !defined($prefix) || $prefix !~ /^\d+$/ || $prefix > 32;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4368_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4368_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4371: 4372: # Compute the network mask and compare masked network addresses 4373: my $mask = ~0 << (32 - $prefix); 4374: my $net_n = unpack 'N', (inet_aton($net_addr) // return 0); 4375: my $ip_n = unpack 'N', (inet_aton($ip) // return 0); 4376: return ($ip_n & $mask) == ($net_n & $mask);Mutants (Total: 5, Killed: 0, Survived: 5)
- NUM_BOUNDARY_4370_65_<: Numeric boundary flip > to <
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4370_65_>=: Numeric boundary flip > to >=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- NUM_BOUNDARY_4370_65_<=: Numeric boundary flip > to <=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- BOOL_NEGATE_4370_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4370_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4377: } 4378: 4379: # _decode_mime_words( $str ) -> decoded_string 4380: # 4381: # Purpose: 4382: # Decode MIME encoded-words (=?charset?B/Q?...?=) in a header value 4383: # string for human-readable display in reports. 4384: # 4385: # Entry criteria: 4386: # $str -- a defined header value string; may be undef. 4387: # 4388: # Exit status: 4389: # Returns the decoded string, or '' if $str is undef. 4390: 4391: sub _decode_mime_words :Private { 4392: my ($self, $str) = @_; 4393: return '' unless defined $str;Mutants (Total: 3, Killed: 0, Survived: 3)
- NUM_BOUNDARY_4376_25_!=: Numeric boundary flip == to !=
HIGH: Likely missing edge-case test (boundary value)🧪 Suggested Test# Boundary test suggestion is( func(VALUE_AT_BOUNDARY), EXPECTED, 'Test boundary behaviour' );- BOOL_NEGATE_4376_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4376_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4394: # Replace each encoded-word with its decoded equivalent 4395: $str =~ s/=\?([^?]+)\?([BbQq])\?([^?]*)\?=/_decode_ew($1,$2,$3)/ge; 4396: return $str;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4393_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4393_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4397: } 4398: 4399: # _decode_ew( $charset, $enc, $text ) -> decoded_bytes 4400: # 4401: # Purpose: 4402: # Decode a single MIME encoded-word component (base64 or quoted-printable). 4403: # 4404: # Notes: 4405: # Non-UTF-8 charsets return raw bytes; good enough for display-name spoof 4406: # detection which only needs ASCII matching. 4407: 4408: sub _decode_ew :Private { ●4409 → 4411 → 4418 4409: my ($charset, $enc, $text) = @_; 4410: my $raw; 4411: if (uc($enc) eq 'B') {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4396_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4396_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4412: $raw = decode_base64($text); 4413: } else { 4414: # Quoted-printable encoded-word uses underscore for space 4415: $text =~ s/_/ /g; 4416: $raw = decode_qp($text); 4417: } 4418: return $raw;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4411_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4419: } 4420: 4421: # _parse_date_to_epoch( $str ) -> epoch_int | undef 4422: # 4423: # Purpose: 4424: # Parse common WHOIS date strings to a Unix epoch integer. 4425: # Handles YYYY-MM-DD, YYYY-MM-DDThh:mm:ssZ, and DD-Mon-YYYY formats. 4426: # 4427: # Entry criteria: 4428: # $str -- a defined date string; may be undef. 4429: # 4430: # Exit status: 4431: # Returns epoch integer on success, undef if the string cannot be parsed. 4432: 4433: sub _parse_date_to_epoch :Private { ●4434 → 4441 → 4457 4434: my ($self, $str) = @_; 4435: return unless $str; 4436: 4437: # Clean the string of trailing whitespace/newlines 4438: $str =~ s/^\s+|\s+$//g; 4439: 4440: # Guard Regex: Validates the strict YYYY-MM-DDThh:mm:ssZ format 4441: if ($str =~ /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4418_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4418_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4442: # Parse the string 4443: # We use 'strptime' to create a Time::Piece object. 4444: # The 'Z' indicates UTC (Zulu time). 4445: my $epoch = eval { 4446: my $t = Time::Piece->strptime($1, '%Y-%m-%dT%H:%M:%S'); 4447: 4448: # Return seconds since the epoch 4449: # Time::Piece handles the timezone offset internally when calling ->epoch 4450: 4451: # strptime returns a local time object. 4452: # We must subtract the local timezone offset to get the true UTC epoch. 4453: return $t->epoch - $t->tzoffset->seconds;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4441_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4454: }; 4455: return $epoch if defined $epoch;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4453_4: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4453_4: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4456: } ●4457 → 4459 → 4463 4457: my ($y, $m, $d); 4458: 4459: if ($str =~ /^(\d{4})-(\d{2})-(\d{2})/) { ($y,$m,$d)=($1,$2,$3) }Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4455_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4455_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4460: elsif ($str =~ /^(\d{2})-([A-Za-z]{3})-(\d{4})/) { ($d,$m,$y)=($1,$Readonly::Values::Months::months{lc$2}//0,$3) } 4461: elsif ($str =~ /^(\d{2})\/(\d{2})\/(\d{4})/) { ($m,$d,$y)=($1,$2,$3) } 4462: ●4463 → 4465 → 4469 4463: return unless $y && $m && $d; 4464: 4465: if (eval { require Time::Local; 1 }) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4459_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4466: return eval { Time::Local::timegm(0,0,0,$d,$m-1,$y-1900) };Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4465_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4467: } 4468: # Approximate fallback without Time::Local 4469: return ($y-1970)*365.25*$SECS_PER_DAY + ($m-1)*30.5*$SECS_PER_DAY + ($d-1)*$SECS_PER_DAY;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4466_3: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4466_3: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4470: } 4471: 4472: # _parse_rfc2822_date( $str ) -> epoch_int | undef 4473: # 4474: # Purpose: 4475: # Parse an RFC 2822 Date: header value to a Unix epoch integer. 4476: # Timezone offsets are intentionally ignored; the function returns a 4477: # UTC-equivalent value. For the 7-day suspicious_date window the 4478: # maximum error is ~14 hours, well within the tolerance. 4479: # 4480: # Entry criteria: 4481: # $str -- a defined Date: header value string. 4482: # 4483: # Exit status: 4484: # Returns epoch integer on success, undef if the string cannot be parsed. 4485: 4486: sub _parse_rfc2822_date :Private { ●4487 → 4491 → 4499 4487: my $str = $_[0]; 4488: return unless $str; 4489: 4490: # Match: DD Mon YYYY HH:MM:SS (timezone offset ignored) 4491: if ($str =~ /(\d{1,2})\s+([A-Za-z]{3})\s+(\d{4})\s+(\d{2}):(\d{2}):(\d{2})/) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4469_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4469_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4492: my ($d, $m, $y, $H, $M, $S) = 4493: ($1, $Readonly::Values::Months::months{ lc $2 } // 0, $3, $4, $5, $6); 4494: return unless $m; 4495: if (eval { require Time::Local; 1 }) {Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4491_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4496: return eval { Time::Local::timegm($S, $M, $H, $d, $m - 1, $y - 1900) };Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4495_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4497: } 4498: } 4499: return; 4500: } 4501: 4502: # _country_name( $cc ) -> country_name_string 4503: # 4504: # Purpose: 4505: # Return a human-readable country name for a two-letter ISO 3166-1 4506: # alpha-2 country code. Only the small set of statistically high-volume 4507: # spam-originating countries is covered; other codes are returned as-is. 4508: # 4509: # Entry criteria: 4510: # $cc -- a two-letter uppercase country code string. 4511: # 4512: # Exit status: 4513: # Returns the country name string, or the code itself if not in the table. 4514: 4515: sub _country_name :Private { 4516: my $cc = $_[0]; 4517: my %names = ( 4518: CN => 'China', RU => 'Russia', NG => 'Nigeria', 4519: VN => 'Vietnam', IN => 'India', PK => 'Pakistan', 4520: BD => 'Bangladesh', 4521: ); 4522: return $names{$cc} // $cc;Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4496_4: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4496_4: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4523: } 4524: 4525: # _debug( $msg ) 4526: # 4527: # Purpose: 4528: # Write a diagnostic message to STDERR when verbose mode is enabled. 4529: # 4530: # Entry criteria: 4531: # $msg -- a defined message string. 4532: # 4533: # Notes: 4534: # Messages are prefixed with the class name for easy grepping. 4535: 4536: sub _debug :Private { ●4537 → 4539 → 0 4537: my ($self, $msg) = @_; 4538: 4539: if($self->{verbose}) {Mutants (Total: 2, Killed: 0, Survived: 2)
- BOOL_NEGATE_4522_2: Negate boolean return expression
MEDIUM: Add tests asserting both true and false outcomes🧪 Suggested Test# Boolean branch test suggestion ok( !func(INPUT), 'Verify boolean branch behaviour' );- RETURN_UNDEF_4522_2: Replace return expression with undef
LOW: Mutation survived, but impact may be minor🧪 Suggested Test# Return value assertion is( func(INPUT), EXPECTED, 'Verify correct return value' );4540: if (my $logger = $self->{logger}) { # Set via Object::ConfigureMutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4539_2: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes4541: $logger->debug("[Email::Abuse::Investigator] $msg"); 4542: } else { 4543: print STDERR "[Email::Abuse::Investigator] $msg\n"; 4544: } 4545: } 4546: } 4547: 4548: 1; 4549: 4550: __END__ 4551: 4552: =head1 ALGORITHM: DOMAIN INTELLIGENCE PIPELINE 4553: 4554: For each unique non-infrastructure domain found in the email, the module 4555: runs the following pipeline: 4556: 4557: Domain name 4558: | 4559: +-- A/AAAA record --> web hosting IP --> RDAP --> org + abuse contact 4560: | 4561: +-- MX record --> mail server hostname --> A --> RDAP --> org + abuse 4562: | 4563: +-- NS record --> nameserver hostname --> A --> RDAP --> org + abuse 4564: | 4565: +-- WHOIS (TLD whois server via IANA referral) 4566: +-- Registrar name + abuse contact 4567: +-- Creation date (-> recently-registered flag if < 180 days) 4568: +-- Expiry date (-> expires-soon or expired flags) 4569: 4570: Domains are collected from: 4571: 4572: From:/Reply-To:/Sender:/Return-Path: headers 4573: DKIM-Signature: d= (signing domain) 4574: List-Unsubscribe: (ESP / bulk sender domain) 4575: Message-ID: (often reveals real sending platform) 4576: mailto: links and bare addresses in the body 4577: 4578: =head1 CACHING 4579: 4580: Two levels of caching are used: 4581: 4582: =over 4 4583: 4584: =item Per-message cache (C<$self-E<gt>{_domain_info}>) 4585: 4586: Stores domain analysis results for the lifetime of one C<parse_email()> 4587: call. Invalidated by each call to C<parse_email()>. 4588: 4589: =item Cross-message cache (CHI Memory driver, if C<CHI> is installed) 4590: 4591: Stores IP WHOIS, DNS resolution, and domain analysis results across all 4592: messages processed by the same process. TTL is one hour. Prevents 4593: redundant WHOIS queries for infrastructure that appears in multiple 4594: messages in the same run (e.g. a sending ISP seen in 500 spam messages). 4595: 4596: =back 4597: 4598: =head1 IPV6 SUPPORT 4599: 4600: IPv6 addresses are extracted from C<Received:> headers using bracketed 4601: notation (C<[2001:db8::1]>). They are tested against the private range 4602: list (which covers ::1, fe80::/10, fc00::/7, fd00::/8, and the 4603: documentation range 2001:db8::/32) and passed through C<_whois_ip()> and 4604: C<_rdap_lookup()> in the same way as IPv4 addresses. 4605: 4606: C<_resolve_host()> attempts both A and AAAA lookups when C<Net::DNS> is 4607: installed. C<_raw_whois()> uses C<IO::Socket::IP> for dual-stack WHOIS 4608: connections when that module is installed. 4609: 4610: =head1 SEE ALSO 4611: 4612: =over 4 4613: 4614: =item * L<Configure an Object at Runtime|Object::Configure> 4615: 4616: The provider_abuse, trusted_domains and url_shorteners tables can all be overridden at runtime 4617: 4618: =item * L<Test Dashboard|https://nigelhorne.github.io/Email-Abuse-Investigator/coverage/> 4619: 4620: =item * L<ARIN RDAP|https://rdap.arin.net/> 4621: 4622: =item * L<Net::DNS>, L<LWP::UserAgent>, L<HTML::LinkExtor> 4623: 4624: =item * L<CHI>, L<AnyEvent::DNS>, L<IO::Socket::IP>, L<Domain::PublicSuffix> 4625: 4626: =back 4627: 4628: =head1 REPOSITORY 4629: 4630: L<https://github.com/nigelhorne/Email-Abuse-Investigator> 4631: 4632: =head1 SUPPORT 4633: 4634: This module is provided as-is without any warranty. 4635: 4636: Please report any bugs or feature requests to C<bug-email-abuse-investigator at rt.cpan.org>, 4637: or through the web interface at 4638: L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Email-Abuse-Investigator>. 4639: I will be notified, and then you'll 4640: automatically be notified of progress on your bug as I make changes. 4641: 4642: You can find documentation for this module with the perldoc command. 4643: 4644: perldoc Email::Abuse::Investigator 4645: 4646: You can also look for information at: 4647: 4648: =over 4 4649: 4650: =item * MetaCPAN 4651: 4652: L<https://metacpan.org/dist/Email-Abuse-Investigator> 4653: 4654: =item * RT: CPAN's request tracker 4655: 4656: L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Email-Abuse-Investigator> 4657: 4658: =item * CPAN Testers' Matrix 4659: 4660: L<http://matrix.cpantesters.org/?dist=Email-Abuse-Investigator> 4661: 4662: =item * CPAN Testers Dependencies 4663: 4664: L<http://deps.cpantesters.org/?module=Email-Abuse-Investigator> 4665: 4666: =back 4667: 4668: =head1 REQUIRED MODULES 4669: 4670: The following modules are mandatory: 4671: 4672: Readonly::Values::Months 4673: Socket (core since Perl 5) 4674: IO::Socket::INET (core since Perl 5) 4675: MIME::QuotedPrint (core since Perl 5.8) 4676: MIME::Base64 (core since Perl 5.8) 4677: 4678: The following are optional but strongly recommended: 4679: 4680: Net::DNS -- enables MX, NS, AAAA record lookups 4681: LWP::UserAgent -- enables RDAP (faster and richer than raw WHOIS) 4682: and following redirect chains for URL shorteners 4683: and cloud-storage redirect cloakers 4684: LWP::ConnCache -- enables HTTP connection reuse (used with LWP::UserAgent) 4685: HTML::LinkExtor -- enables structural HTML link extraction with entity decoding 4686: CHI -- enables cross-message IP/domain result caching 4687: IO::Socket::IP -- enables IPv6 WHOIS connections 4688: Domain::PublicSuffix -- enables accurate eTLD+1 domain normalisation 4689: AnyEvent::DNS -- enables parallel DNS resolution for multiple URL hosts 4690: 4691: =head1 LIMITATIONS 4692: 4693: =over 4 4694: 4695: =item No charset conversion 4696: 4697: Body text is stored as raw bytes. Non-ASCII content (UTF-8, Latin-1, 4698: ISO-2022-JP, etc.) is not decoded to Perl's internal Unicode representation. 4699: URL and domain extraction from non-ASCII bodies may miss or misparse content. 4700: Use C<Email::MIME> if full charset support is needed. 4701: 4702: =item Hand-rolled MIME parser 4703: 4704: The built-in MIME parser handles common cases but is not a conforming 4705: implementation of RFC 2045/2046. It silently drops parts it cannot decode, 4706: does not handle C<message/rfc822> attachments, and does not parse 4707: C<Content-Disposition> filenames. Replace with C<Email::MIME> or 4708: C<MIME::Entity> for production use with untrusted input. 4709: 4710: =item IPv4-only CIDR matching for trusted_relays 4711: 4712: C<_ip_in_cidr()> and the C<trusted_relays> constructor argument only support 4713: IPv4 CIDR notation. IPv6 trusted relay entries are accepted but silently 4714: never match. 4715: 4716: =item WHOIS rate-limiting not handled 4717: 4718: C<_raw_whois()> does not retry on rate-limit responses (typically a 4719: "quota exceeded" reply). Under high-volume processing the module will 4720: silently return empty enrichment data for affected IPs and domains. 4721: 4722: =item Not thread-safe 4723: 4724: The class-level C<$_cache> variable and the optional-module C<$HAS_*> flags 4725: are shared across all threads. Create a separate object per thread and do 4726: not share objects across threads. 4727: 4728: =item DMARC policy not fetched 4729: 4730: The module reads the C<Authentication-Results: dmarc=> result from the 4731: message headers but does not perform live C<_dmarc.domain> TXT record 4732: lookups. A missing DMARC result in the headers is not independently flagged. 4733: 4734: =item C<abuse_contacts()> routes duplicated in C<form_contacts()> 4735: 4736: Both methods iterate the same six discovery routes independently. Any new 4737: discovery route must be added to both. A future refactor should share a 4738: single routing pass. 4739: 4740: =item CHI cache is a class-level mutable global 4741: 4742: The cross-message cache is shared across all instances in the process. 4743: Tests that populate the cache will affect subsequent tests. Pass the cache 4744: in via C<new()> (not currently supported) to enable proper isolation. 4745: 4746: =back 4747: 4748: =encoding utf-8 4749: 4750: =head1 FORMAL SPECIFICATION 4751: 4752: =head2 new 4753: 4754: -- Z notation (simplified) 4755: new == [ 4756: timeout : N; 4757: trusted_relays : seq STRING; 4758: verbose : BOOL; 4759: _raw : STRING; 4760: _headers : seq (STRING x STRING); 4761: _origin? : IP_INFO | undefined; 4762: _urls? : seq URL_INFO | undefined; 4763: _risk? : RISK_INFO | undefined 4764: ] 4765: pre: timeout >= 0 4766: post: self.timeout = params.timeout /\ self._raw = '' 4767: 4768: =head2 parse_email 4769: 4770: -- Z notation 4771: parse_email == [ 4772: Delta Email::Abuse::Investigator; 4773: text? : STRING | ref STRING 4774: ] 4775: pre: defined text? 4776: post: self._raw = deref(text?) /\ 4777: self._origin = undefined /\ 4778: self._urls = undefined /\ 4779: self._risk = undefined 4780: 4781: =head2 originating_ip 4782: 4783: -- Z notation 4784: originating_ip == [ 4785: Xi Email::Abuse::Investigator; 4786: result! : IP_INFO | undefined 4787: ] 4788: pre: self._raw /= '' 4789: post: result! = self._origin /\ 4790: (result! /= undefined => result!.ip in EXTERNAL_IPS) 4791: 4792: =head2 embedded_urls 4793: 4794: -- Z notation 4795: embedded_urls == [ 4796: Xi Email::Abuse::Investigator; 4797: result! : seq URL_INFO 4798: ] 4799: pre: self._raw /= '' 4800: post: result! = self._urls /\ 4801: forall u : result! @ u.url =~ m{^https?://}i 4802: 4803: =head2 mailto_domains 4804: 4805: -- Z notation 4806: mailto_domains == [ 4807: Xi Email::Abuse::Investigator; 4808: result! : seq DOMAIN_INFO 4809: ] 4810: pre: self._raw /= '' 4811: post: result! = self._mailto_domains /\ 4812: forall d : result! @ d.domain =~ /\.[a-zA-Z]{2,}$/ 4813: 4814: =head2 all_domains 4815: 4816: -- Z notation 4817: all_domains == [ 4818: Xi Email::Abuse::Investigator; 4819: result! : seq STRING 4820: ] 4821: post: result! = deduplicate( 4822: map(_registrable, url_hosts union mailto_domains) 4823: ) 4824: 4825: =head2 unresolved_contacts 4826: 4827: -- Z notation 4828: unresolved_contacts == [ 4829: Xi Email::Abuse::Investigator; 4830: result! : seq UNRESOLVED_INFO 4831: ] 4832: post: forall u : result! @ 4833: u.domain not_in covered_domains(abuse_contacts, form_contacts) 4834: 4835: =head2 sending_software 4836: 4837: -- Z notation 4838: sending_software == [ 4839: Xi Email::Abuse::Investigator; 4840: result! : seq SW_INFO 4841: ] 4842: post: result! = self._sending_sw 4843: 4844: =head2 received_trail 4845: 4846: -- Z notation 4847: received_trail == [ 4848: Xi Email::Abuse::Investigator; 4849: result! : seq HOP_INFO 4850: ] 4851: post: result! = self._rcvd_tracking 4852: 4853: =head2 risk_assessment 4854: 4855: -- Z notation 4856: risk_assessment == [ 4857: Xi Email::Abuse::Investigator; 4858: result! : RISK_INFO 4859: ] 4860: post: result!.score = sum({ w(f.severity) | f in result!.flags }) /\ 4861: result!.level = classify(result!.score) 4862: where: 4863: w(HIGH) = 3; w(MEDIUM) = 2; w(LOW) = 1; w(INFO) = 0 4864: classify(s) = HIGH if s >= 9 4865: | MEDIUM if s >= 5 4866: | LOW if s >= 2 4867: | INFO otherwise 4868: 4869: =head2 abuse_report_text 4870: 4871: -- Z notation 4872: abuse_report_text == [ 4873: Xi Email::Abuse::Investigator; 4874: result! : STRING 4875: ] 4876: post: result! /= '' /\ result! ends_with '\n' 4877: 4878: =head2 abuse_contacts 4879: 4880: -- Z notation 4881: abuse_contacts == [ 4882: Xi Email::Abuse::Investigator; 4883: result! : seq CONTACT_INFO 4884: ] 4885: post: forall c : result! @ c.address contains '@' /\ 4886: forall c1, c2 : result! @ c1 /= c2 => c1.address /= c2.address 4887: 4888: =head2 form_contacts 4889: 4890: -- Z notation 4891: form_contacts == [ 4892: Xi Email::Abuse::Investigator; 4893: result! : seq FORM_CONTACT_INFO 4894: ] 4895: post: forall c : result! @ c.form =~ m{^https?://} /\ 4896: forall c1, c2 : result! @ c1 /= c2 => c1.form /= c2.form 4897: 4898: =head2 report 4899: 4900: -- Z notation 4901: report == [ 4902: Xi Email::Abuse::Investigator; 4903: result! : STRING 4904: ] 4905: post: result! /= '' /\ result! ends_with '\n' 4906: 4907: =head2 header_value 4908: 4909: header_value : Object à FieldName â Maybe FieldValue 4910: header_value(o, n) â first { lc(h.name) = lc(n) } o._headers .value 4911: 4912: =head1 LICENCE AND COPYRIGHT 4913: 4914: Copyright 2026 Nigel Horne. 4915: 4916: Usage is subject to GPL2 licence terms. 4917: If you use it, 4918: please let me know. 4919: 4920: =cut 4921: 4922: 1;Mutants (Total: 1, Killed: 0, Survived: 1)
- COND_INV_4540_3: Invert condition if to unless
MEDIUM: Add tests asserting both true and false outcomes