File Coverage

File:bin/submit_abuse_report
Coverage:49.4%

linestmtbrancondsubtimecode
1#!/usr/bin/env perl
2# -----------------------------------------------------------------------
3# submit_abuse_report -- analyse a spam/phishing email and send abuse
4#                           reports to all relevant parties.
5#
6# Usage:
7#   submit_abuse_report [options] spam.eml
8#   submit_abuse_report [options] < spam.eml
9#
10# Options:
11#   --dry-run              Show what would be sent and to whom; do not
12#                          actually connect to any mail server.
13#   -i, --interactive      Prompt for confirmation before each send.
14#   --bcc                  Send a copy to the --from address for monitoring.
15#   --bcc-to ADDRESS       Send a copy to an explicit address.
16#   --from ADDRESS         Envelope / From: address for the outgoing
17#                          reports (required unless --dry-run).
18#   --smtp HOST[:PORT]     SMTP relay to use (default: localhost:25).
19#   --trusted CIDR         Trusted relay CIDR to skip in Received: chain.
20#                          May be repeated for multiple relays.
21#   --timeout SECS         Network timeout in seconds (default: 15).
22#   --verbose              Print analysis progress to STDERR.
23#   --help                 Show this help text.
24#
25# Examples:
26#   # Dry run -- see what would be sent without sending anything
27#   submit_abuse_report --dry-run spam.eml
28#
29#   # Send reports via the local MTA
30#   submit_abuse_report --from postmaster@myisp.example spam.eml
31#
32#   # Send via a specific SMTP relay, skipping our own outbound IP
33#   submit_abuse_report \
34#       --from abuse-reporter@example.com \
35#       --smtp mail.example.com:587 \
36#       --trusted 203.0.113.0/24 \
37#       spam.eml
38# -----------------------------------------------------------------------
39
7
7
14463
10
use 5.010;
40
7
7
7
11
7
64
use strict;
41
7
7
7
10
6
109
use warnings;
42
7
7
7
1241
701
17
use utf8;
43
7
7
7
1373
3479
18
use open qw(:std :encoding(UTF-8));
44
45
7
7
7
57559
32379
13
use Getopt::Long qw(GetOptions);
46
7
7
7
1951
134381
1381
use Pod::Usage   qw(pod2usage);
47
7
7
7
3864
333099
235
use Net::SMTP;
48
7
7
7
1322
2113
229
use MIME::Base64 qw(encode_base64);
49
7
7
7
22
5
21
use POSIX        qw(strftime);
50
7
7
7
200
6
218
use File::Basename qw(basename);
51
7
7
7
1248
2079
16
use lib '.';     # find Email/Abuse/Investigator.pm in development tree
52
7
7
7
3402
16
17411
use Email::Abuse::Investigator;
53
54# -----------------------------------------------------------------------
55# Command-line options
56# -----------------------------------------------------------------------
57
7
734579
my $dry_run     = 0;
58
7
10
my $interactive = 0;
59
7
8
my $bcc      = 0;      # --bcc flag: copy to --from address
60
7
8
my $bcc_to   = '';     # --bcc-to ADDRESS: copy to explicit address
61
7
12
my $from     = '';
62
7
101
my $smtp_arg = 'localhost:25';
63
7
55
my $timeout  = 15;
64
7
6
my $verbose  = 0;
65
7
27
my $help     = 0;
66
7
7
my @trusted;
67
68
7
31
GetOptions(
69    'dry-run'    => \$dry_run,
70    'interactive|i' => \$interactive,
71    'bcc'        => \$bcc,
72    'bcc-to=s'   => \$bcc_to,
73    'from=s'     => \$from,
74    'smtp=s'     => \$smtp_arg,
75    'trusted=s'  => \@trusted,
76    'timeout=i'  => \$timeout,
77    'verbose'    => \$verbose,
78    'help'       => \$help,
79) or pod2usage(2);
80
81
7
3745
pod2usage( -exitval => 0, -verbose => 2 ) if $help;
82
83
5
11
unless ($dry_run || $from) {
84
0
0
    die basename($0) . ": --from ADDRESS is required unless --dry-run is used.\n"
85      . "Run with --help for usage.\n";
86}
87
88
5
12
if ($from && $from !~ /\@/) {
89
0
0
    die basename($0) . ": --from value '$from' does not look like an email address.\n";
90}
91
92
5
30
my ($smtp_host, $smtp_port) = split /:/, $smtp_arg, 2;
93
94# Resolve BCC address.
95# --bcc alone copies to --from; --bcc-to ADDRESS uses an explicit address.
96
5
6
my $bcc_addr = undef;
97
5
10
if ($bcc_to) {
98
0
0
    die basename($0) . ": --bcc-to value '$bcc_to' does not look like an email address.\n"
99        unless $bcc_to =~ /\@/;
100
0
0
    $bcc_addr = $bcc_to;
101} elsif ($bcc) {
102
0
0
    die basename($0) . ": --bcc requires --from to be set.\n"
103        unless $from;
104
0
0
    $bcc_addr = $from;
105}
106
5
10
$smtp_port //= 25;
107
108# -----------------------------------------------------------------------
109# Read the raw email
110# -----------------------------------------------------------------------
111
5
6
my $raw;
112
5
8
if (@ARGV) {
113        # Validate path: reject NUL bytes, bare newlines, and directory-traversal
114        # sequences before opening.  This also makes the path taint-safe under -T.
115
5
15
        my ($safe_path) = $ARGV[0] =~ /\A([^\x00\r\n]+)\z/
116                or die basename($0) . ": invalid characters in path '$ARGV[0]'\n";
117
5
96
        die basename($0) . ": path traversal attempt in '$safe_path'\n"
118                if $safe_path =~ m{(?:^|/)\.\.(?:/|$)};
119
4
102
        open my $fh, '<:raw', $safe_path or die basename($0) . ": cannot open '$safe_path': $!";
120
4
8
        local $/;
121
4
64
        $raw = <$fh>;
122
4
23
        close $fh;
123} else {
124
0
0
        binmode STDIN, ':raw';
125
0
0
        local $/;
126
0
0
        $raw = <STDIN>;
127}
128
129
4
14
die basename($0) . ": no email data supplied.\n"
130    unless defined $raw && length $raw;
131
132# -----------------------------------------------------------------------
133# Analyse
134# -----------------------------------------------------------------------
135
4
7
print STDERR "Analysing message...\n" if $verbose;
136
137
4
23
my $inv = Email::Abuse::Investigator->new(
138    timeout        => $timeout,
139    trusted_relays => \@trusted,
140    verbose        => $verbose,
141);
142
4
13
$inv->parse_email(\$raw);
143
144
4
12
my @contacts = $inv->abuse_contacts();
145
146
4
7
unless (@contacts) {
147
2
9
    print STDERR "No abuse contacts could be determined.  Nothing to send.\n";
148
149    # List any domains and URL hosts that were found but could not be
150    # resolved to an abuse contact, so the user knows where to look.
151
2
6
    _print_unresolved($inv, \*STDERR);
152
2
46
    exit 0;
153}
154
155# -----------------------------------------------------------------------
156# _print_unresolved( $inv, $fh )
157#
158# Print a list of domains and URL hosts found in the message that could
159# not be resolved to an abuse contact, so the user knows where to look.
160# -----------------------------------------------------------------------
161sub _print_unresolved {
162
4
6
    my ($inv, $fh) = @_;
163
164    # Delegate to the module's unresolved_contacts() method which handles
165    # all the filtering logic (spoofable headers, already-covered domains).
166
4
27
    my @unresolved = $inv->unresolved_contacts();
167
4
8
    return unless @unresolved;
168
169
4
7
    print $fh "\nDomains found in this message with no abuse contact determined\n";
170
4
6
    print $fh "(consider investigating or reporting manually):\n\n";
171
4
10
    for my $u (@unresolved) {
172
4
7
        if ($u->{type} eq 'url_host') {
173
3
6
            print $fh "  URL host : $u->{domain}\n";
174        } else {
175
1
3
            print $fh "  Domain   : $u->{domain}\n";
176        }
177    }
178
4
9
    print $fh "\n";
179}
180
181
2
6
my $report_text = $inv->abuse_report_text();
182
183# Append a note to the human-readable part so recipients know the
184# original message is attached and why.
185
2
4
$report_text .= join("\n",
186    "",
187    "-" x 72,
188    "The original spam/phishing message is attached below as a",
189    "message/rfc822 MIME part.  Please use the full Received: headers",
190    "to locate the relevant SMTP session in your mail logs.",
191    "-" x 72,
192    "");
193
194
2
2
my $orig        = $inv->originating_ip();
195
2
4
my $risk        = $inv->risk_assessment();
196
197# Build a consistent subject line for all outgoing reports
198
2
5
my $subject = _build_subject($orig, $risk);
199
200# -----------------------------------------------------------------------
201# Dry run: describe what would be sent
202# -----------------------------------------------------------------------
203
2
4
if ($dry_run) {
204
2
5
    _dry_run_report(\@contacts, $subject, $report_text, \$raw, $inv, $bcc_addr);
205
2
22
    exit 0;
206}
207
208# -----------------------------------------------------------------------
209# Live run: send one email per unique abuse contact
210# -----------------------------------------------------------------------
211
0
0
my $sent  = 0;
212
0
0
my $failed = 0;
213
214
0
0
for my $contact (@contacts) {
215
0
0
    my $to   = $contact->{address};
216
0
0
    my $role = $contact->{role};
217
218
0
0
    if ($interactive) {
219
0
0
        printf "\nSend abuse report to: %s\n", $to;
220
0
0
        printf "Reason             : %s\n", $role;
221
0
0
        print  "Send? [y/N] ";
222
0
0
        my $ans = _read_tty();
223
0
0
        unless (defined $ans && lc($ans) eq 'y') {
224
0
0
            printf "Skipped: %-45s  %s\n", $to, $role;
225
0
0
            next;
226        }
227    }
228
229
0
0
    print STDERR "Sending to $to ($role)...\n" if $verbose;
230
231
0
0
    my $ok = _send_report(
232        smtp_host   => $smtp_host,
233        smtp_port   => $smtp_port,
234        from        => $from,
235        to          => $to,
236        bcc         => $bcc_addr,
237        subject     => $subject,
238        body        => $report_text,
239        original    => \$raw,
240        inv         => $inv,
241        timeout     => $timeout,
242    );
243
244
0
0
    if ($ok) {
245
0
0
        printf "Sent   : %-45s  %s\n", $to, $role;
246
0
0
        $sent++;
247    } else {
248
0
0
        printf "FAILED : %-45s  %s\n", $to, $role;
249
0
0
        $failed++;
250    }
251}
252
253
0
0
printf "\nDone: %d sent, %d failed.\n", $sent, $failed;
254
0
0
_print_unresolved($inv, \*STDOUT);
255
0
0
exit( $failed ? 1 : 0 );
256
257# -----------------------------------------------------------------------
258# _dry_run_report( \@contacts, $subject, $report_text, \$raw, $inv )
259#
260# Print a full description of what would be sent without sending anything.
261# -----------------------------------------------------------------------
262sub _dry_run_report {
263
2
4
    my ($contacts, $subject, $body, $raw_ref, $inv, $bcc) = @_;
264
265
2
2
    my $bar = '=' x 72;
266
2
7
    print "$bar\n";
267
2
2
    print "  DRY RUN -- no email will be sent\n";
268
2
4
    print "$bar\n\n";
269
270
2
5
    printf "Subject  : %s\n\n", $subject;
271
2
2
    print  "Envelope sender: <> (null reverse-path per RFC 6650 s.3)\n";
272
2
4
    printf "BCC copy to    : %s\n", $bcc if $bcc;
273
2
2
    print  "\n";
274
275
2
4
    printf "%-3s  %-45s  %s\n", '#', 'Recipient', 'Role';
276
2
3
    printf "%-3s  %-45s  %s\n", '-' x 3, '-' x 45, '-' x 24;
277
278
2
3
    my $n = 1;
279
2
2
0
3
    for my $c (@{$contacts}) {
280
2
5
        printf "%-3d  %-45s  %s\n", $n++, $c->{address}, $c->{role};
281        printf "     via: %-42s  note: %s\n",
282
2
6
            $c->{via}, ($c->{note} || '');
283
2
2
        print "\n";
284    }
285
286    # Part 1
287
2
2
    print "$bar\n";
288
2
2
    print "  PART 1: HUMAN-READABLE REPORT (text/plain)\n";
289
2
2
    print "  (identical text sent to every recipient above)\n";
290
2
2
    print "$bar\n\n";
291
2
59
    print $body;
292
293    # Part 2: feedback-report fields
294
2
7
    print "\n$bar\n";
295
2
3
    print "  PART 2: ARF METADATA (message/feedback-report)\n";
296
2
3
    print "  RFC 5965 s.3 -- machine-readable fields for automated processing\n";
297
2
17
    print "$bar\n\n";
298
2
6
    my $fbr = _build_feedback_report($inv);
299
2
16
    print "  $_\n" for split /\r?\n/, $fbr;
300
301    # Part 3: original message preview
302
2
2
    print "\n$bar\n";
303
2
3
    print "  PART 3: ORIGINAL MESSAGE (message/rfc822)\n";
304
2
2
    print "  RFC 5965 s.2 -- verbatim original; MUST be included\n";
305
2
3
    print "$bar\n";
306
2
8
    if (defined $raw_ref && length $$raw_ref) {
307
2
11
        my @lines = split /\r?\n/, $$raw_ref;
308
2
2
        my $preview = 20;
309
2
2
        my $total   = scalar @lines;
310
2
6
        printf "  [Showing first %d of %d lines]\n\n",
311            ($total < $preview ? $total : $preview), $total;
312
2
39
        print "  $_\n" for @lines[ 0 .. ($total < $preview ? $total - 1 : $preview - 1) ];
313
2
8
        print "  ...\n" if $total > $preview;
314    } else {
315
0
0
        print "  (no original message available)\n";
316    }
317
318
2
5
    print "\n$bar\n";
319
320    # abuse_contacts() now merges duplicate addresses, so @contacts has one
321    # entry per unique address with 'role' already set to the combined string.
322    # Count the total discovery routes (sum of all roles arrays) to show
323    # in the annotation when merging occurred.
324
2
2
3
4
    my $n_contacts = scalar @{$contacts};
325
2
3
    my $n_routes   = 0;
326
2
2
2
3
5
5
    $n_routes += scalar @{ $_->{roles} // [$_->{role}] } for @{$contacts};
327
328
2
4
    if ($n_routes > $n_contacts) {
329        # Some addresses cover more than one discovery route -- note the merge
330
0
0
        printf "  Total: %d recipient%s (%d contact route%s merged)\n",
331            $n_contacts, $n_contacts == 1 ? '' : 's',
332            $n_routes,   $n_routes   == 1 ? '' : 's';
333    } else {
334
2
28
        printf "  Total: %d recipient%s\n",
335            $n_contacts, $n_contacts == 1 ? '' : 's';
336    }
337
2
4
    print "\n";
338
339    # One line per recipient; 'role' is already the merged string
340
2
2
2
12
    for my $c (@{$contacts}) {
341
2
6
        printf "  %s (%s)\n", $c->{address}, $c->{role};
342    }
343
344    # Web-form contacts -- providers that do not accept email
345
2
5
    my @form_cs = $inv->form_contacts();
346
2
3
    if (@form_cs) {
347
0
0
        print "\n$bar\n";
348
0
0
        print "  MANUAL ACTION REQUIRED -- WEB FORM SUBMISSION\n";
349
0
0
        print "  The following parties do not accept email abuse reports.\n";
350
0
0
        print "  Open each URL in a browser and complete the form as instructed.\n";
351
0
0
        print "$bar\n\n";
352
0
0
        for my $c (@form_cs) {
353
0
0
            printf "  Role     : %s\n", $c->{role};
354
0
0
            printf "  Form URL : %s\n", $c->{form};
355
0
0
            printf "  Domain   : %s\n", $c->{form_domain} if $c->{form_domain};
356
0
0
            if ($c->{form_paste}) {
357
0
0
                printf "  Paste    : %s\n", $c->{form_paste};
358            }
359
0
0
            if ($c->{form_upload}) {
360
0
0
                printf "  Upload   : %s\n", $c->{form_upload};
361            }
362
0
0
            print "\n";
363        }
364    }
365
366
2
6
    print "$bar\n";
367
368
2
8
    _print_unresolved($inv, \*STDOUT);
369}
370
371# -----------------------------------------------------------------------
372# _read_tty() -> string | undef
373#
374# Read a single line from the controlling terminal.  Extracted into its
375# own sub so tests can override it without spawning a pseudo-terminal.
376# Returns the chomped input string, or undef if /dev/tty is unavailable.
377# -----------------------------------------------------------------------
378sub _read_tty {
379
0
0
    my $tty;
380
0
0
    unless (open $tty, '<', '/dev/tty') {
381
0
0
        warn "Cannot open /dev/tty for interactive prompt; skipping\n";
382
0
0
        return undef;
383    }
384
0
0
    my $ans = <$tty>;
385
0
0
    close $tty;
386
0
0
    chomp $ans if defined $ans;
387
0
0
    return $ans;
388}
389
390# -----------------------------------------------------------------------
391# _send_report( %args ) -> bool
392#
393# Send a single ARF-compliant abuse report via SMTP.
394# Returns 1 on success, 0 on failure.
395# Errors are printed to STDERR; the caller continues to the next recipient.
396#
397# Envelope sender is the null reverse-path (<>) per RFC 6650 s.3, which
398# requires this to prevent mail loops and DSN storms.  The From: header
399# still carries the reporter's address for human reply purposes.
400# -----------------------------------------------------------------------
401sub _send_report {
402
0
0
    my (%a) = @_;
403
404
0
0
    my $date = strftime('%a, %d %b %Y %H:%M:%S +0000', gmtime);
405    my $msg_id = sprintf '<%s.%d@%s>',
406        strftime('%Y%m%d%H%M%S', gmtime),
407        $$,
408
0
0
0
0
0
0
        do { (my $h = $a{from}) =~ s/.*\@//; $h };
409
410    my $mail = _build_mime_message(
411        date     => $date,
412        from     => $a{from},
413        to       => $a{to},
414        bcc      => $a{bcc},
415        subject  => $a{subject},
416        msg_id   => $msg_id,
417        body     => $a{body},
418        original => $a{original},
419        inv      => $a{inv},
420
0
0
    );
421
422    # Developer aid: set SUBMIT_ABUSE_DUMP_MIME=path to write the raw MIME
423    # message to a file for inspection (e.g. SUBMIT_ABUSE_DUMP_MIME=/tmp/out.eml).
424
0
0
    if (my $dump = $ENV{SUBMIT_ABUSE_DUMP_MIME}) {
425
0
0
        if (open my $fh, '>', $dump) {
426
0
0
            print $fh $mail;
427
0
0
            close $fh;
428
0
0
            warn "MIME message written to $dump\n";
429        }
430    }
431
432
0
0
    my $smtp = eval {
433        Net::SMTP->new(
434            $a{smtp_host},
435            Port    => $a{smtp_port},
436            Timeout => $a{timeout},
437
0
0
        );
438    };
439
440
0
0
    unless ($smtp) {
441
0
0
        warn "SMTP connect to $a{smtp_host}:$a{smtp_port} failed: $@\n";
442
0
0
        return 0;
443    }
444
445
0
0
    my $ok = eval {
446        # Null reverse-path per RFC 6650 s.3 -- prevents DSN/mail loops
447
0
0
        $smtp->mail( '' )         or die "MAIL FROM failed\n";
448
0
0
        $smtp->to(   $a{to} )     or die "RCPT TO failed\n";
449        $smtp->to(   $a{bcc} )    or die "RCPT TO (bcc) failed\n"
450
0
0
            if $a{bcc};
451
0
0
        $smtp->data()             or die "DATA failed\n";
452
0
0
        $smtp->datasend($mail)    or die "datasend failed\n";
453
0
0
        $smtp->dataend()          or die "dataend failed\n";
454
0
0
        $smtp->quit();
455
0
0
        1;
456    };
457
458
0
0
    unless ($ok) {
459
0
0
        my $err = $@ || 'unknown SMTP error';
460
0
0
        $err =~ s/\s+$//;
461
0
0
        warn "SMTP error sending to $a{to}: $err\n";
462
0
0
        return 0;
463    }
464
465
0
0
    return 1;
466}
467
468# -----------------------------------------------------------------------
469# _build_feedback_report( $inv ) -> string
470#
471# Constructs the body of the message/feedback-report MIME part (Part 2)
472# as defined in RFC 5965 s.3.  The returned string uses CRLF line endings
473# and contains only 7-bit ASCII, as required by the RFC.
474#
475# Fields included:
476#   Feedback-Type        -- always "abuse"
477#   User-Agent           -- module name and version
478#   Version              -- always "1" (RFC 5965 version)
479#   Source-IP            -- originating IP from Received: chain analysis
480#   Original-Mail-From   -- Return-Path: or From: of the spam
481#   Original-Rcpt-To     -- To: of the spam
482#   Arrival-Date         -- Date: header of the spam (as received)
483#   Reported-Domain      -- primary contact domain (first of all_domains())
484#   Reported-Uri         -- each HTTP/HTTPS URL found in the spam body
485#   Authentication-Results -- forwarded from the spam's own header
486# -----------------------------------------------------------------------
487sub _build_feedback_report {
488
2
6
    my ($inv) = @_;
489
490
2
2
    my @fields;
491
492    # Required fields (RFC 5965 s.3.1)
493
2
4
    push @fields, 'Feedback-Type: abuse';
494
2
4
    push @fields, 'User-Agent: Email::Abuse::Investigator/'
495                . $Email::Abuse::Investigator::VERSION;
496
2
2
    push @fields, 'Version: 1';
497
498    # Source-IP -- the identified originating address
499
2
6
    my $orig = $inv->originating_ip();
500
2
3
    push @fields, "Source-IP: $orig->{ip}" if $orig && $orig->{ip};
501
502    # Original-Mail-From -- envelope sender of the spam
503    # Prefer Return-Path (true envelope sender); fall back to From:
504
2
6
    my $mail_from = $inv->header_value('return-path')
505                 // $inv->header_value('from')
506                 // '';
507
2
35
    $mail_from =~ s/^\s*<?\s*|\s*>?\s*$//g;   # strip angle brackets and whitespace
508
2
4
    push @fields, "Original-Mail-From: <$mail_from>" if $mail_from;
509
510    # Original-Rcpt-To -- envelope recipient(s) of the spam
511    # Use the To: header; for each distinct address found
512
2
4
    my $rcpt_to = $inv->header_value('to') // '';
513    # Extract individual addresses (bare addr@domain or <addr@domain>)
514
2
2
    my %rcpt_seen;
515
2
9
    while ($rcpt_to =~ /<?([^\s<>,;]+\@[\w.-]+)>?/g) {
516
2
3
        my $addr = lc $1;
517
2
12
        push @fields, "Original-Rcpt-To: <$addr>" unless $rcpt_seen{$addr}++;
518    }
519
520    # Arrival-Date -- when we (the reporter) received the spam
521
2
4
    my $arrival = $inv->header_value('date') // '';
522
2
5
    push @fields, "Arrival-Date: $arrival" if $arrival;
523
524    # Reported-Domain -- the primary contact domain
525
2
4
    my ($rdomain) = $inv->all_domains();
526
2
4
    push @fields, "Reported-Domain: $rdomain" if $rdomain;
527
528    # Reported-Uri -- each distinct URL in the spam body
529
2
2
    my %uri_seen;
530
2
4
    for my $u ($inv->embedded_urls()) {
531        push @fields, "Reported-Uri: $u->{url}"
532
2
6
            unless $uri_seen{ $u->{url} }++;
533    }
534
535    # Authentication-Results -- forwarded verbatim from the spam headers
536
2
3
    my $auth_res = $inv->header_value('authentication-results') // '';
537
2
3
    push @fields, "Authentication-Results: $auth_res" if $auth_res;
538
539
2
7
    return join("\r\n", @fields) . "\r\n";
540}
541
542# -----------------------------------------------------------------------
543# _build_mime_message( %args ) -> string
544#
545# Constructs a fully RFC 5965 compliant multipart/report MIME message
546# suitable for transmission via Net::SMTP->datasend().
547#
548# Three-part structure per RFC 5965 s.2:
549#   Part 1  text/plain; charset=UTF-8  -- human-readable abuse report
550#   Part 2  message/feedback-report    -- ARF machine-readable metadata
551#   Part 3  message/rfc822             -- original spam message verbatim
552#
553# All line endings in the returned string are CRLF (\r\n).
554# Part 2 uses 7bit encoding (required by RFC 5965 s.3).
555# -----------------------------------------------------------------------
556sub _build_mime_message {
557
0
0
    my (%a) = @_;
558
559    # Boundary unique per message; must not appear in any part body
560
0
0
    my $boundary = sprintf 'arf_report_%s_%d',
561        strftime('%Y%m%d%H%M%S', gmtime), $$;
562
563    # Normalise line endings to CRLF throughout, then encode to raw UTF-8
564    # bytes.  Net::SMTP->datasend() calls syswrite() on a raw socket and
565    # cannot handle Perl strings with the Unicode flag set (wide characters).
566    # The body may contain non-ASCII characters (e.g. emoji in decoded subject
567    # lines) so we must encode explicitly rather than rely on the socket layer.
568
0
0
    (my $body_crlf     = $a{body}) =~ s/\r?\n/\r\n/g;
569
0
0
    my $original_crlf  = '';
570
0
0
0
0
    if (defined $a{original} && length ${ $a{original} }) {
571
0
0
0
0
        ($original_crlf = ${ $a{original} }) =~ s/\r?\n/\r\n/g;
572    }
573
0
0
    my $feedback_report = _build_feedback_report($a{inv});
574
575    # ---- Outer envelope headers ----
576
0
0
    my @msg;
577
0
0
    push @msg, "Date: $a{date}";
578
0
0
    push @msg, "From: $a{from}";
579
0
0
    push @msg, "To: $a{to}";
580
0
0
    push @msg, "Subject: $a{subject}";
581
0
0
    push @msg, "Message-ID: $a{msg_id}";
582
0
0
    push @msg, "MIME-Version: 1.0";
583    # multipart/report with report-type=feedback-report per RFC 5965 s.2
584
0
0
    push @msg, "Content-Type: multipart/report;";
585
0
0
    push @msg, "    report-type=feedback-report;";
586
0
0
    push @msg, "    boundary=\"$boundary\"";
587
0
0
    push @msg, "X-Mailer: Email::Abuse::Investigator submit_abuse_report";
588
0
0
    push @msg, "X-Report-Monitor: $a{bcc}" if $a{bcc};
589
0
0
    push @msg, "";
590
591    # Preamble for non-MIME clients
592
0
0
    push @msg, "This is an ARF (Abuse Reporting Format) feedback report.";
593
0
0
    push @msg, "See https://datatracker.ietf.org/doc/html/rfc5965";
594
0
0
    push @msg, "";
595
596    # ---- Part 1: human-readable summary (RFC 5965 s.2 "first part") ----
597
0
0
    push @msg, "--$boundary";
598
0
0
    push @msg, "Content-Type: text/plain; charset=UTF-8";
599
0
0
    push @msg, "Content-Transfer-Encoding: 8bit";
600
0
0
    push @msg, "Content-Disposition: inline";
601
0
0
    push @msg, "";
602
0
0
    push @msg, $body_crlf;
603
604    # ---- Part 2: ARF machine-readable metadata (RFC 5965 s.3) ----
605
0
0
    push @msg, "--$boundary";
606
0
0
    push @msg, "Content-Type: message/feedback-report";
607
0
0
    push @msg, "Content-Transfer-Encoding: 7bit";   # required by RFC 5965
608
0
0
    push @msg, "";
609
0
0
    push @msg, $feedback_report;
610
611    # ---- Part 3: original spam message (RFC 5965 s.2 "third part") ----
612    # Use base64 encoding so Outlook and other clients reliably show
613    # it as a downloadable .eml attachment rather than rendering it
614    # inline or discarding it.
615
0
0
    my $original_b64 = MIME::Base64::encode_base64($original_crlf, "\r\n");
616
0
0
    push @msg, "--$boundary";
617
0
0
    push @msg, "Content-Type: application/octet-stream; name=\"original_message.eml.txt\"";
618
0
0
    push @msg, "Content-Transfer-Encoding: base64";
619
0
0
    push @msg, "Content-Disposition: attachment;";
620
0
0
    push @msg, "    filename=\"original_message.eml.txt\"";
621
0
0
    push @msg, "Content-Description: Original spam/phishing message";
622
0
0
    push @msg, "";
623
0
0
    push @msg, $original_b64;
624
625    # Closing boundary
626
0
0
    push @msg, "--${boundary}--";
627
0
0
    push @msg, "";
628
629
0
0
    require Encode;
630
0
0
    my $result = join("\r\n", @msg);
631
0
0
    return Encode::encode('UTF-8', $result);
632}
633
634# -----------------------------------------------------------------------
635# _build_subject( $orig_hashref, $risk_hashref ) -> string
636#
637# Build a concise, informative subject line for the abuse report.
638# -----------------------------------------------------------------------
639sub _build_subject {
640
2
2
    my ($orig, $risk) = @_;
641
642
2
5
    my $ip_part   = $orig ? $orig->{ip} : 'unknown origin';
643
2
5
    my $level     = $risk->{level};
644
2
62
    my $date_part = strftime('%Y-%m-%d', gmtime);
645
646
2
5
    return "Abuse report [$level]: spam/phishing from $ip_part ($date_part)";
647}
648