Email-Abuse-Investigator

 view release on metacpan or  search on metacpan

t/extended_tests.t  view on Meta::CPAN

	ok scalar(grep { /badshamart\.example/ } @hosts),
		'correct hostname extracted from nested HTML part';
	restore_net();
};

subtest 'nested MIME: three levels deep -- URLs still extracted' => sub {
	# Ensure recursion handles arbitrary nesting depth, not just two levels.
	null_net();
	my $b1 = 'boundary_level1';
	my $b2 = 'boundary_level2';
	my $b3 = 'boundary_level3';

	my $level3 = join("\r\n",
		"--$b3",
		'Content-Type: text/html; charset=us-ascii',
		'',
		'<a href="https://deep.nested.example/path">deep link</a>',
		"--${b3}--",
		'',
	);
	my $level2 = join("\r\n",
		"--$b2",
		"Content-Type: multipart/alternative; boundary=\"$b3\"",
		'',
		$level3,
		"--${b2}--",
		'',
	);
	my $level1 = join("\r\n",
		"--$b1",
		"Content-Type: multipart/related; boundary=\"$b2\"",
		'',
		$level2,
		"--${b1}--",
		'',
	);

	my $raw = join("\r\n",
		'Received: from ext (ext [198.51.100.1]) by mx.nigelhorne.com with ESMTP',
		'From: Spammer <spam@spammer.example>',
		'To: <victim@nigelhorne.com>',
		'Subject: Deep nesting test',
		'Date: ' . POSIX::strftime('%a, %d %b %Y %H:%M:%S +0000', gmtime),
		'Message-ID: <deep@test>',
		"Content-Type: multipart/mixed; boundary=\"$b1\"",
		'',
		$level1,
	);

	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email($raw);

	my @urls = $a->embedded_urls();
	ok scalar(@urls) > 0,
		'embedded_urls() finds URLs three MIME levels deep';
	ok scalar(grep { $_->{host} eq 'deep.nested.example' } @urls),
		'correct hostname extracted from three-level nested part';
	restore_net();
};

subtest 'nested MIME: non-multipart sibling parts still decoded' => sub {
	# A multipart/mixed with one attachment part and one multipart/alternative
	# part -- the attachment must be skipped cleanly and the alternative decoded.
	null_net();
	my $outer = 'outer_sib_001';
	my $inner = 'inner_sib_001';

	my $body = join("\r\n",
		"--$outer",
		'Content-Type: application/octet-stream',
		'Content-Disposition: attachment; filename="file.bin"',
		'',
		'binarydata',
		"--$outer",
		"Content-Type: multipart/alternative; boundary=\"$inner\"",
		'',
		"--$inner",
		'Content-Type: text/plain',
		'',
		'Plain sibling text.',
		"--$inner",
		'Content-Type: text/html',
		'',
		'<a href="https://sibling.example/link">click</a>',
		"--${inner}--",
		'',
		"--${outer}--",
		'',
	);

	my $raw = join("\r\n",
		'Received: from ext (ext [198.51.100.1]) by mx.nigelhorne.com with ESMTP',
		'From: Spammer <spam@spammer.example>',
		'To: <victim@nigelhorne.com>',
		'Subject: Sibling parts test',
		'Date: ' . POSIX::strftime('%a, %d %b %Y %H:%M:%S +0000', gmtime),
		'Message-ID: <sib@test>',
		"Content-Type: multipart/mixed; boundary=\"$outer\"",
		'',
		$body,
	);

	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email($raw);

	my @urls = $a->embedded_urls();
	ok scalar(grep { $_->{host} eq 'sibling.example' } @urls),
		'URL in multipart/alternative sibling of attachment part is found';
	restore_net();
};

# =============================================================================
# 39. Regression: abuse_contacts() role merging (0.04 fix)
#	 Before the fix, duplicate addresses from multiple discovery routes
#	 were silently dropped; now they accumulate into roles/role.
# =============================================================================

subtest 'abuse_contacts role merging -- roles arrayref accumulates all routes' => sub {
	# Arrange a message where the same provider (Google) is found via two
	# distinct routes: as the sending ISP (rDNS match) and as a URL host.
	# Both roles must appear in the merged entry.
	null_net();
	my $a = new_ok('Email::Abuse::Investigator');

t/extended_tests.t  view on Meta::CPAN

		'abuse_report_text() form contact has Form line';
	like $art, qr/Paste\s+:/,
		'abuse_report_text() form contact has Paste line (form_paste set)';
	like $art, qr/Upload\s+:/,
		'abuse_report_text() form contact has Upload line (form_upload set)';
};

subtest 'abuse_report_text() -- with risk flags' => sub {
	# Ensure the RED FLAGS block in abuse_report_text() is exercised. Use a
	# free-webmail From: (gmail) to trigger the free_webmail risk flag.
	no warnings 'redefine';
	local *Email::Abuse::Investigator::_reverse_dns  = sub { undef };
	local *Email::Abuse::Investigator::_resolve_host = sub { undef };
	local *Email::Abuse::Investigator::_whois_ip     = sub { {} };
	local *Email::Abuse::Investigator::_domain_whois = sub { undef };
	local *Email::Abuse::Investigator::_rdap_lookup  = sub { {} };
	local *Email::Abuse::Investigator::_raw_whois    = sub { undef };

	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email(make_email(
		from => 'Spammer <spam@gmail.com>',
		to   => '<victim@nigelhorne.com>',
		body => 'Buy now.',
	));
	my $art = $a->abuse_report_text();
	like $art, qr/RED FLAGS IDENTIFIED/,
		'abuse_report_text() RED FLAGS section present when flags exist';
	like $art, qr/RISK LEVEL:/,
		'abuse_report_text() contains RISK LEVEL line';
};

subtest 'abuse_report_text() -- with originating IP and abuse contacts' => sub {
	# Exercises the ORIGINATING IP and ABUSE CONTACTS blocks.
	no warnings 'redefine';
	local *Email::Abuse::Investigator::_reverse_dns  = sub { 'mail.evil.example' };
	local *Email::Abuse::Investigator::_resolve_host = sub { undef };
	local *Email::Abuse::Investigator::_whois_ip     = sub {
		{ org => 'Evil Corp', abuse => 'abuse@evil.example' }
	};
	local *Email::Abuse::Investigator::_domain_whois = sub { undef };
	local *Email::Abuse::Investigator::_rdap_lookup  = sub { {} };
	local *Email::Abuse::Investigator::_raw_whois    = sub { undef };

	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email(make_email(
		received => 'from evil.example (evil.example [91.198.174.20])'
		          . ' by mx.nigelhorne.com with ESMTP',
		to       => '<victim@nigelhorne.com>',
		body     => 'Buy now.',
	));
	my $art = $a->abuse_report_text();
	like $art, qr/ORIGINATING IP:\s+91\.198\.174\.20/,
		'abuse_report_text() ORIGINATING IP line present';
	like $art, qr/NETWORK OWNER:\s+Evil Corp/,
		'abuse_report_text() NETWORK OWNER line present';
	like $art, qr/ABUSE CONTACTS:/,
		'abuse_report_text() ABUSE CONTACTS section present';
};

# =============================================================================
# 56. report() -- MIME-encoded subject header: $decoded ne $v branch
#
# When a header value contains RFC 2047 encoded-words, _decode_mime_words()
# returns a different string. The ternary in report() appends
# "  [encoded: $v]" to show the original alongside the decoded value.
# =============================================================================

subtest 'report() -- MIME-encoded Subject shows decoded and original' => sub {
	no warnings 'redefine';
	local *Email::Abuse::Investigator::_reverse_dns  = sub { undef };
	local *Email::Abuse::Investigator::_resolve_host = sub { undef };
	local *Email::Abuse::Investigator::_whois_ip     = sub { {} };
	local *Email::Abuse::Investigator::_domain_whois = sub { undef };
	local *Email::Abuse::Investigator::_rdap_lookup  = sub { {} };
	local *Email::Abuse::Investigator::_raw_whois    = sub { undef };

	# =?UTF-8?B?...?= encodes "Special Offer"
	my $encoded_subj = '=?UTF-8?B?U3BlY2lhbCBPZmZlcg==?=';
	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email(make_email(
		to      => '<victim@nigelhorne.com>',
		subject => $encoded_subj,
		body    => 'Buy now.',
	));
	my $rpt = $a->report();
	like $rpt, qr/Special Offer/,
		'report() shows decoded value of MIME-encoded Subject';
	like $rpt, qr/\[encoded:.*=\?UTF-8\?B\?/,
		'report() appends [encoded: original] suffix for encoded Subject';
};

# =============================================================================
# 57. unresolved_contacts() -- all branch paths
#
# Target the branches:
#  a) URL with abuse = "(unknown)" -> covered check false -> appears in result
#  b) URL with real abuse -> covered -> NOT in result
#  c) Duplicate URL bare host -> seen{"url:$bare"}++ fires
#  d) Domain from From:/Return-Path:/Sender: header -> skipped by source filter
#  e) Domain from Reply-To: header -> NOT skipped
#  f) Domain already covered by abuse_contacts() -> not in result
#  g) unless ($dom) -> fires when form_contacts entry has no address / form_domain
# =============================================================================

subtest 'unresolved_contacts() -- URL with unknown abuse appears unresolved' => sub {
	# _whois_ip returning {} means $u->{abuse} is absent (set to '(unknown)' by
	# _extract_and_resolve_urls). The covered-check in unresolved_contacts is
	# false, so the URL host appears in the unresolved list.
	no warnings 'redefine';
	local *Email::Abuse::Investigator::_reverse_dns  = sub { undef };
	local *Email::Abuse::Investigator::_resolve_host = sub { '1.2.3.4' };
	local *Email::Abuse::Investigator::_whois_ip     = sub { {} };
	local *Email::Abuse::Investigator::_domain_whois = sub { undef };
	local *Email::Abuse::Investigator::_rdap_lookup  = sub { {} };
	local *Email::Abuse::Investigator::_raw_whois    = sub { undef };

	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email(make_email(
		to   => '<victim@nigelhorne.com>',
		body => 'Visit https://unknown-isp-host.example/offer now',
	));
	my @u = $a->unresolved_contacts();
	my ($entry) = grep { $_->{domain} =~ /unknown-isp-host/ } @u;
	ok defined $entry,
		'URL host with unknown abuse appears in unresolved_contacts()';
	is $entry->{type}, 'url_host',
		'entry type is url_host';
};

subtest 'unresolved_contacts() -- URL with real abuse not in unresolved' => sub {
	# When $u->{abuse} is a real address (not "(unknown)"), covered{$bare} is
	# incremented and the host is suppressed from the unresolved list.
	no warnings 'redefine';
	local *Email::Abuse::Investigator::_reverse_dns  = sub { undef };
	local *Email::Abuse::Investigator::_resolve_host = sub { '5.6.7.8' };
	local *Email::Abuse::Investigator::_whois_ip     = sub {
		{ abuse => 'abuse@known-isp.example' }
	};
	local *Email::Abuse::Investigator::_domain_whois = sub { undef };
	local *Email::Abuse::Investigator::_rdap_lookup  = sub { {} };
	local *Email::Abuse::Investigator::_raw_whois    = sub { undef };

	my $a = new_ok('Email::Abuse::Investigator');
	$a->parse_email(make_email(
		to   => '<victim@nigelhorne.com>',
		body => 'Visit https://covered-host.example/offer now',
	));



( run in 0.620 second using v1.01-cache-2.11-cpan-9169edd2b0e )