| # <@LICENSE> |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to you under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at: |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # </@LICENSE> |
| |
| =head1 NAME |
| |
| Mail::SpamAssassin::Handler::Image - A MIME-part handler for image/* parts |
| |
| =head1 SYNOPSIS |
| |
| loadhandler Mail::SpamAssassin::Handler::Image |
| |
| imagetext RULE_NAME /pattern/modifiers |
| |
| body IMAGE_TEXT_HEAVY eval:check_image_text_ratio('0.75') |
| describe IMAGE_TEXT_HEAVY Most of the body text came from images |
| |
| =head1 DESCRIPTION |
| |
| A handler that registers itself as the MIME-part handler for C<image/*> parts, |
| runs the C<tesseract> OCR engine on them, and injects the recognised text into |
| the message body, so ordinary body rules can match text that would otherwise be |
| hidden inside an image. |
| |
| It also provides the C<imagetext> rule type, which matches a regular expression |
| specifically against the OCR'd image text: |
| |
| imagetext RULE_NAME /pattern/modifiers |
| |
| These rules behave like C<body> rules and support the C<multiple> and |
| C<maxhits=N> tflags. By default a rule stops at its first match. |
| |
| =head1 TFLAGS |
| |
| =over 4 |
| |
| =item multiple |
| |
| Match the rule more than once (for use with meta rules counting hits). |
| |
| =item maxhits=N |
| |
| With C<multiple>, stop after N hits. |
| |
| =back |
| |
| =head1 CONFIGURATION |
| |
| =over 4 |
| |
| =item image_tesseract_path /path/to/tesseract |
| |
| Full path to the C<tesseract> executable. If unset, the plugin looks for |
| C<tesseract> on the C<PATH>. If it cannot be found OCR is disabled with a |
| warning, so C<--lint> never fails merely because the binary is absent. |
| |
| =item image_ocr_lang eng |
| |
| Language(s) passed to C<tesseract -l> (default C<eng>). |
| |
| =item image_heif_convert_path /path/to/heif-convert |
| |
| Full path to C<heif-convert> (from libheif), used to convert HEIF/HEIC images |
| to PNG before OCR, since tesseract cannot read HEIF natively. If unset, the |
| plugin looks for C<heif-convert> on the C<PATH>. If it is unavailable, HEIF |
| images are skipped while all other image types are still OCR'd. The real image |
| type is detected from the file's leading bytes, not its declared Content-Type, |
| so a HEIF image mislabelled as e.g. image/png is still handled. |
| |
| =item image_ocr_min_width N (default: 0) |
| |
| Skip OCR for any image narrower than N pixels. Small images (logos, icons, |
| spacers, tracking pixels) rarely contain readable text, so this avoids wasting |
| tesseract on them. C<0> disables the check. Dimensions are read from the image |
| header (PNG, JPEG, GIF, WebP, BMP); when they cannot be determined the image is |
| OCR'd anyway, so this is a best-effort optimisation, not an anti-evasion control. |
| |
| =item image_ocr_min_height N (default: 0) |
| |
| As C<image_ocr_min_width>, but for image height. C<0> disables the check. An |
| image is skipped if it is below I<either> the minimum width I<or> the minimum |
| height. |
| |
| =back |
| |
| =cut |
| |
| package Mail::SpamAssassin::Handler::Image; |
| |
| use strict; |
| use warnings; |
| use re 'taint'; |
| |
| use Mail::SpamAssassin::Handler; |
| use Mail::SpamAssassin::Logger qw(dbg info would_log); |
| use Mail::SpamAssassin::Timeout; |
| use Mail::SpamAssassin::Util qw(untaint_var untaint_file_path |
| proc_status_ok exit_status_str compile_regexp); |
| |
| our @ISA = qw(Mail::SpamAssassin::Handler); |
| |
| sub new { |
| my ($class, $main) = @_; |
| $class = ref($class) || $class; |
| my $self = $class->SUPER::new($main); |
| bless ($self, $class); |
| |
| $self->set_config($main->{conf}); |
| |
| # Register as the handler for every image type, and an eval rule to query the |
| # result. |
| $self->register_handler('image/*', 'handle_image'); |
| $self->register_eval_rule('check_image_text_ratio', |
| $Mail::SpamAssassin::Conf::TYPE_BODY_EVALS); |
| |
| return $self; |
| } |
| |
| sub set_config { |
| my ($self, $conf) = @_; |
| my @cmds = ( |
| { |
| setting => 'image_tesseract_path', |
| is_admin => 1, |
| default => '', |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, |
| }, |
| { |
| setting => 'image_ocr_lang', |
| is_admin => 1, |
| default => 'eng', |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, |
| }, |
| { |
| setting => 'image_heif_convert_path', |
| is_admin => 1, |
| default => '', |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, |
| }, |
| { |
| setting => 'image_ocr_min_width', |
| is_admin => 1, |
| default => 0, |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC, |
| }, |
| { |
| setting => 'image_ocr_min_height', |
| is_admin => 1, |
| default => 0, |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_NUMERIC, |
| }, |
| { |
| # imagetext RULENAME /pattern/modifiers |
| # Define a rule that matches against text OCR'd from images. The compiled |
| # regex is stored and run later (finish_parsing_end builds the match loop); |
| # here we only record it and register an empty test so scores and --lint |
| # work. |
| setting => 'imagetext', |
| is_priv => 1, |
| type => $Mail::SpamAssassin::Conf::CONF_TYPE_STRING, |
| code => sub { |
| my ($self, $key, $value, $line) = @_; |
| if ($value !~ /^(\S+)\s+(.+)$/) { |
| return $Mail::SpamAssassin::Conf::INVALID_VALUE; |
| } |
| my ($name, $pattern) = ($1, $2); |
| my ($re, $err) = compile_regexp($pattern, 1); |
| if (!$re) { |
| dbg("image: invalid imagetext regexp for $name '$pattern': $err"); |
| return $Mail::SpamAssassin::Conf::INVALID_VALUE; |
| } |
| $conf->{imagetext_rules}->{$name} = $re; |
| $self->{parser}->add_test($name, undef, |
| $Mail::SpamAssassin::Conf::TYPE_EMPTY_TESTS); |
| }, |
| }, |
| ); |
| $conf->{parser}->register_commands(\@cmds); |
| } |
| |
| # After config is fully parsed, compile all imagetext rules into a single sub |
| # (_run_imagetext_rules) so the per-rule match loops run as compiled code, |
| # mirroring how native body rules are built. Each default rule stops at its |
| # first hit (if + last); "multiple" rules scan with /g and honour maxhits=N. |
| sub finish_parsing_end { |
| my ($self, $opts) = @_; |
| my $conf = $opts->{conf}; |
| |
| return unless exists $conf->{imagetext_rules}; |
| |
| my $would_log = would_log('dbg'); |
| |
| my $eval = <<'EOF'; |
| package Mail::SpamAssassin::Handler::Image; |
| |
| sub _run_imagetext_rules { |
| my ($self, $opts) = @_; |
| my $pms = $opts->{permsgstatus}; |
| my ($test_qr, $hits); |
| |
| my $image_text = $self->_get_image_text($pms); |
| return unless @$image_text; |
| |
| EOF |
| |
| my $loopid = 0; |
| foreach my $name (keys %{$conf->{imagetext_rules}}) { |
| $loopid++; |
| my $tflags = $conf->{tflags}->{$name} || ''; |
| |
| my ($dbg_running_rule, $dbg_ran_rule) = ('', ''); |
| if ($would_log) { |
| $dbg_running_rule = qq(dbg("running rule $name");); |
| $dbg_ran_rule = qq(dbg(qq(ran rule $name ======> got hit "\$match"));); |
| } |
| |
| my $ifwhile = 'if'; |
| my $last = 'last;'; |
| my $modifiers = 'p'; |
| my $init_hits = ''; |
| |
| if ($tflags =~ /\bmultiple\b/) { |
| $ifwhile = 'while'; |
| $modifiers .= 'g'; |
| if ($tflags =~ /\bmaxhits=(\d+)\b/) { |
| $init_hits = "\$hits = 0;"; |
| $last = "last rule_$loopid if ++\$hits >= $1;"; |
| } else { |
| $last = ''; |
| } |
| } |
| |
| $eval .= <<"EOF"; |
| $dbg_running_rule |
| \$test_qr = \$pms->{conf}->{imagetext_rules}->{$name}; |
| $init_hits |
| rule_$loopid: foreach my \$line (\@\$image_text) { |
| $ifwhile ( \$line =~ /\$test_qr/$modifiers ) { |
| my \$match = defined \${^MATCH} ? \${^MATCH} : '<negative match>'; |
| $dbg_ran_rule |
| \$pms->got_hit('$name', 'IMAGETEXT: ', 'ruletype' => 'body'); |
| $last |
| } |
| } |
| EOF |
| } |
| |
| $eval .= "}\n"; |
| |
| # The generated sub replaces the no-op _run_imagetext_rules placeholder below. |
| no warnings 'redefine'; |
| eval untaint_var($eval); |
| if ($@) { |
| die("image: error compiling imagetext rules: $@"); |
| } |
| } |
| |
| # Real implementation is compiled in by finish_parsing_end; this is the no-op |
| # used when no imagetext rules are configured. |
| sub _run_imagetext_rules { } |
| |
| sub parsed_metadata { |
| my ($self, $opts) = @_; |
| $self->_run_imagetext_rules($opts); |
| } |
| |
| # The text source for imagetext rules: the per-image OCR text that handle_image |
| # stashed on $pms (in document order). Cached per scan. |
| sub _get_image_text { |
| my ($self, $pms) = @_; |
| return $pms->{plugins}{Image}{text} || []; |
| } |
| |
| # handle_image($node, $pms): OCR one image part. Inject any recognised text via |
| # set_rendered() and accumulate the per-message OCR image and word counts (used |
| # by check_image_text_ratio). Produces no child parts. |
| sub handle_image { |
| my ($self, $node, $pms) = @_; |
| |
| my $tesseract = $self->_tesseract($pms->{conf}); |
| return [] unless $tesseract; |
| |
| my $data = $node->decode; |
| return [] unless defined $data && length $data; |
| |
| # The image bytes can be large, so pass them by reference throughout (avoids |
| # copying the whole scalar into each helper's @_). |
| my $dataref = \$data; |
| |
| # Identify the real type and dimensions from the bytes (not the declared |
| # Content-Type, which spammers mislabel). One parse feeds both the size gate |
| # and the HEIF check. |
| my ($type, $w, $h) = $self->_image_info($dataref); |
| |
| # Type gate: only OCR bytes we positively identify as a raster format tesseract |
| # can read. An undef type means the bytes are not one of our known rasters -- |
| # most importantly image/svg+xml (XML, not a bitmap), which reaches this |
| # image/* handler when no SVG handler is loaded, but also unknown or corrupt |
| # blobs. Feeding any of those to tesseract is a wasted fork (and can emit junk |
| # text), so skip them. ($w/$h may still be undef for a recognised type -- e.g. |
| # HEIF, whose dimensions come from the converted PNG -- so gate on $type only.) |
| if (!defined $type) { |
| dbg("image: skipping OCR, unrecognised image format (type %s)", |
| $node->{type} // '?'); |
| return []; |
| } |
| |
| # Size gate: skip OCR for images too small to plausibly hold readable text |
| # (logos, icons, spacers, tracking pixels). Fail open when dimensions are |
| # unknown, so an unmeasurable format can't be used to dodge OCR. |
| my $minw = $pms->{conf}->{image_ocr_min_width} || 0; |
| my $minh = $pms->{conf}->{image_ocr_min_height} || 0; |
| if (($minw || $minh) && defined $w && defined $h) { |
| if (($minw && $w < $minw) || ($minh && $h < $minh)) { |
| dbg("image: skipping OCR, %dx%d below min %dx%d", $w, $h, $minw, $minh); |
| return []; |
| } |
| } |
| |
| # HEIF/HEIC must be converted to PNG before tesseract. _heif_to_png returns a |
| # ref to the converted bytes, which becomes the new source for OCR. |
| if (($type // '') eq 'heif') { |
| my $pngref = $self->_heif_to_png($pms, $dataref); |
| return [] unless defined $pngref; # no converter, or conversion failed |
| $dataref = $pngref; |
| } |
| |
| my $text = $self->_ocr($pms, $dataref, $node->{type}); |
| return [] unless defined $text; |
| |
| # Normalise: collapse the trailing whitespace tesseract emits. |
| $text =~ s/\s+\z//; |
| return [] unless length $text; |
| |
| # Render the text under the part's own (image) type, not the default |
| # text/plain. This mirrors ExtractText: it keeps the body-text assembly from |
| # discarding the OCR text as a "text attachment" when the image carried |
| # Content-Disposition: attachment (the common case for image attachments) -- |
| # that skip only fires for rendered types matching text/*. |
| $node->set_rendered($text, $node->effective_type); |
| # Accumulate per-message OCR image and word counts (used by |
| # check_image_text_ratio to spot a mostly-text "image"). |
| my $word_count = () = $text =~ /\S+/g; |
| $pms->{plugins}{Image}{ocr_image_count}++; |
| $pms->{plugins}{Image}{ocr_word_count} += $word_count; |
| # Stash the text for imagetext rules (see _run_imagetext_rules); one entry per |
| # OCR'd image, in document order. |
| push @{$pms->{plugins}{Image}{text}}, $text; |
| dbg("image: OCR'd %d bytes (%d words) of text from %s", |
| length $text, $word_count, $node->{type} || '?'); |
| |
| return []; |
| } |
| |
| # Resolve the tesseract binary lazily, on first use, and cache the result. |
| # Deferred out of new() on purpose: the plugin is constructed during config |
| # parsing, possibly BEFORE image_tesseract_path has been seen, so reading config |
| # in new() would be order-dependent. By first handle_image() time all config is |
| # parsed. Returns the path, or undef (OCR then no-ops) if unavailable. |
| sub _tesseract { |
| my ($self, $conf) = @_; |
| return $self->{tesseract} if exists $self->{tesseract}; |
| $self->{tesseract} = $self->_resolve_binary( |
| 'tesseract', $conf->{image_tesseract_path}, 'tesseract', |
| 'Image disabled, tesseract executable not found (set image_tesseract_path)'); |
| return $self->{tesseract}; |
| } |
| |
| # heif-convert turns HEIC/HEIF (which tesseract/leptonica can't read) into PNG. |
| # Optional: if absent, HEIF images are simply skipped, everything else still |
| # OCRs. Resolved lazily and cached, like the tesseract path. |
| sub _heif_convert { |
| my ($self, $conf) = @_; |
| return $self->{heif_convert} if exists $self->{heif_convert}; |
| $self->{heif_convert} = $self->_resolve_binary( |
| 'heif-convert', $conf->{image_heif_convert_path}, 'heif-convert', undef); |
| return $self->{heif_convert}; |
| } |
| |
| # Resolve an external binary: explicit config path first, else search PATH. |
| # Returns the untainted path or undef. $warn_msg, if set, is warned once when |
| # the binary is missing (used for the required tesseract; omitted for optional |
| # converters, which fail quietly). |
| sub _resolve_binary { |
| my ($self, $label, $cfg_path, $exe, $warn_msg) = @_; |
| my $path = $cfg_path; |
| if (!defined $path || $path eq '') { |
| $path = Mail::SpamAssassin::Util::find_executable_in_env_path($exe); |
| } |
| if (defined $path && -x $path) { |
| $path = untaint_file_path($path); |
| dbg("image: using $label at $path"); |
| return $path; |
| } |
| warn "image: $warn_msg\n" if defined $warn_msg; |
| dbg("image: $label not found, related conversions disabled") |
| if !defined $warn_msg; |
| return; |
| } |
| |
| # Identify an image's real type and pixel dimensions from its leading bytes. |
| # The declared Content-Type / filename extension is attacker-controlled and |
| # routinely mismatched to dodge OCR, so we read the bytes directly. Takes a |
| # scalar ref to the (possibly large) bytes to avoid copying. Returns |
| # ($type, $width, $height): |
| # $type - 'png'/'jpeg'/'gif'/'webp'/'bmp'/'heif', or undef if unrecognized. |
| # $width - pixels, or undef if not parseable (HEIF, or a corrupt/truncated |
| # $height header). HEIF is identified for conversion but its dimensions are |
| # not read here (we OCR the converted PNG instead). |
| # Byte offsets adapted from Plugin::ImageInfo; defensive throughout. |
| sub _image_info { |
| my ($self, $dataref) = @_; |
| my $len = defined $$dataref ? length $$dataref : 0; |
| return (undef, undef, undef) unless $len >= 16; |
| |
| # PNG: 8-byte signature, then IHDR with width/height as big-endian uint32. |
| if (substr($$dataref, 0, 8) eq "\x89PNG\x0d\x0a\x1a\x0a") { |
| my ($w, $h) = unpack('NN', substr($$dataref, 16, 8)); |
| return ('png', ($w && $h) ? ($w, $h) : (undef, undef)); |
| } |
| |
| # GIF: "GIF87a"/"GIF89a", then width/height as little-endian uint16. |
| if (substr($$dataref, 0, 6) =~ /^GIF8[79]a$/) { |
| my ($w, $h) = unpack('vv', substr($$dataref, 6, 4)); |
| return ('gif', ($w && $h) ? ($w, $h) : (undef, undef)); |
| } |
| |
| # JPEG: SOI (FFD8), then walk segments to a Start-Of-Frame marker, whose |
| # payload is precision(1) height(2 BE) width(2 BE). |
| if (substr($$dataref, 0, 2) eq "\xFF\xD8") { |
| my $pos = 2; |
| while ($pos + 4 <= $len) { |
| my ($ff, $mark, $seg) = unpack('CCn', substr($$dataref, $pos, 4)); |
| last if $ff != 0xFF || $mark == 0xDA || $mark == 0xD9 || $seg < 2; |
| if ( ($mark >= 0xC0 && $mark <= 0xC3) || ($mark >= 0xC5 && $mark <= 0xC7) || |
| ($mark >= 0xC9 && $mark <= 0xCB) || ($mark >= 0xCD && $mark <= 0xCF) ) { |
| last if $pos + 9 > $len; |
| my (undef, $h, $w) = unpack('Cnn', substr($$dataref, $pos + 4, 5)); |
| return ('jpeg', ($w && $h) ? ($w, $h) : (undef, undef)); |
| } |
| $pos += 2 + $seg; # 2-byte marker + segment length (which includes itself) |
| } |
| return ('jpeg', undef, undef); |
| } |
| |
| # WebP: "RIFF"...."WEBP", then a chunk fourcc selecting the dimension layout. |
| if (substr($$dataref, 0, 4) eq 'RIFF' && substr($$dataref, 8, 4) eq 'WEBP') { |
| my $fourcc = substr($$dataref, 12, 4); |
| if ($fourcc eq 'VP8 ' && $len >= 30) { # lossy |
| my ($w, $h) = unpack('vv', substr($$dataref, 26, 4)); |
| $w &= 0x3fff; $h &= 0x3fff; |
| return ('webp', ($w && $h) ? ($w, $h) : (undef, undef)); |
| } |
| if ($fourcc eq 'VP8L' && $len >= 25) { # lossless |
| my $b = unpack('V', substr($$dataref, 21, 4)); |
| my $w = ($b & 0x3fff) + 1; |
| my $h = (($b >> 14) & 0x3fff) + 1; |
| return ('webp', $w, $h); |
| } |
| if ($fourcc eq 'VP8X' && $len >= 30) { # extended |
| my $wb = substr($$dataref, 24, 3) . "\x00"; |
| my $hb = substr($$dataref, 27, 3) . "\x00"; |
| my $w = unpack('V', $wb) + 1; |
| my $h = unpack('V', $hb) + 1; |
| return ('webp', $w, $h); |
| } |
| return ('webp', undef, undef); |
| } |
| |
| # BMP: "BM", width/height as little-endian int32 (height may be negative for |
| # top-down bitmaps). |
| if (substr($$dataref, 0, 2) eq 'BM' && $len >= 26) { |
| my $w = unpack('l<', substr($$dataref, 18, 4)); |
| my $h = unpack('l<', substr($$dataref, 22, 4)); |
| $w = abs $w; $h = abs $h; |
| return ('bmp', ($w && $h) ? ($w, $h) : (undef, undef)); |
| } |
| |
| # TIFF: "II\x2a\x00" (little-endian) or "MM\x00\x2a" (big-endian), then the |
| # offset of the first IFD. Dimensions come from the ImageWidth (256) and |
| # ImageLength (257) tags, which may be SHORT or LONG. Only the first IFD is |
| # read: a multi-page TIFF is measured by its first page, which is what an image |
| # reader shows and enough for the size gate. |
| if (substr($$dataref, 0, 4) eq "II\x2a\x00" || |
| substr($$dataref, 0, 4) eq "MM\x00\x2a") { |
| my $le = substr($$dataref, 0, 2) eq 'II'; |
| my $ifd = unpack($le ? 'V' : 'N', substr($$dataref, 4, 4)); |
| my ($w, $h); |
| # Guard every read against a truncated or bogus offset; a malformed TIFF must |
| # fall through as a known type with unknown dimensions, not die. |
| if ($ifd >= 8 && $ifd + 2 <= $len) { |
| my $count = unpack($le ? 'v' : 'n', substr($$dataref, $ifd, 2)); |
| $count = 512 if $count > 512; # sanity cap on entries to walk |
| for my $i (0 .. $count - 1) { |
| my $e = $ifd + 2 + $i * 12; |
| last if $e + 12 > $len; |
| my ($tag, $type) = unpack($le ? 'vv' : 'nn', substr($$dataref, $e, 4)); |
| next unless $tag == 256 || $tag == 257; |
| # SHORT (3) sits in the low half of the value field; LONG (4) fills it. |
| my $v = $type == 3 ? unpack($le ? 'v' : 'n', substr($$dataref, $e + 8, 2)) |
| : $type == 4 ? unpack($le ? 'V' : 'N', substr($$dataref, $e + 8, 4)) |
| : undef; |
| next unless defined $v; |
| $tag == 256 ? ($w = $v) : ($h = $v); |
| last if defined $w && defined $h; |
| } |
| } |
| return ('tiff', ($w && $h) ? ($w, $h) : (undef, undef)); |
| } |
| |
| # ISO-BMFF / HEIF: bytes 4-7 are 'ftyp', bytes 8-11 a brand code. Identified |
| # for conversion; dimensions read from the converted PNG, not here. |
| if (substr($$dataref, 4, 4) eq 'ftyp') { |
| my $brand = substr($$dataref, 8, 4); |
| return ('heif', undef, undef) |
| if $brand =~ /^(?:heic|heix|hevc|hevx|heim|heis|hevm|hevs| |
| mif1|msf1|heif)$/x; |
| } |
| |
| return (undef, undef, undef); |
| } |
| |
| # Run tesseract on the decoded image bytes, mirroring ExtractText's invocation: |
| # write bytes to a secure temp file, run "tesseract <file> - -l <lang>", read the |
| # recognised text from the pipe. Returns the text, or undef on failure. |
| sub _ocr { |
| my ($self, $pms, $dataref, $type) = @_; |
| |
| my $conf = $pms->{conf} || $self->{main}->{conf}; |
| my $lang = $conf->{image_ocr_lang} || 'eng'; |
| my $secs = $conf->{handler_time_limit} || 10; |
| |
| my ($tmp_file, $err_file, $pid, $resp, $errno); |
| |
| Mail::SpamAssassin::PerMsgStatus::enter_helper_run_mode($pms); |
| |
| # tesseract uses OpenMP; one thread keeps it from fighting the MTA for cores. |
| local $ENV{OMP_THREAD_LIMIT} = 1; |
| |
| my $timer = Mail::SpamAssassin::Timeout->new( |
| { secs => $secs, deadline => $pms->{master_deadline} }); |
| |
| my $err = $timer->run_and_catch(sub { |
| local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" }; |
| |
| ($tmp_file, my $tmp_fh) = Mail::SpamAssassin::Util::secure_tmpfile(); |
| $tmp_file or die "failed to create a temporary file\n"; |
| print $tmp_fh $$dataref; |
| close($tmp_fh); |
| $tmp_file = untaint_file_path($tmp_file); |
| |
| ($err_file, my $err_fh) = Mail::SpamAssassin::Util::secure_tmpfile(); |
| $err_file or die "failed to create a temporary file\n"; |
| close($err_fh); |
| $err_file = untaint_file_path($err_file); |
| |
| # tesseract <imagefile> <outputbase> ...; outputbase "-" means stdout. |
| my @cmd = ($self->{tesseract}, $tmp_file, '-', |
| '-l', untaint_var($lang), '-c', 'page_separator='); |
| |
| $pid = Mail::SpamAssassin::Util::helper_app_pipe_open( |
| *IMAGE_OCR, undef, ">$err_file", @cmd); |
| $pid or die "$!\n"; |
| |
| my ($inbuf, $nread); |
| $resp = ''; |
| while ($nread = read(IMAGE_OCR, $inbuf, 8192)) { $resp .= $inbuf } |
| defined $nread or die "error reading from pipe: $!\n"; |
| |
| $errno = 0; |
| close IMAGE_OCR or $errno = $!; |
| |
| if (proc_status_ok($?, $errno)) { |
| dbg("image: tesseract [%s] finished successfully", $pid); |
| } else { |
| dbg("image: tesseract [%s] finished: %s", |
| $pid, exit_status_str($?, $errno)); |
| } |
| }); |
| |
| # Reap a stale child if the timer fired mid-run. |
| if (defined(fileno(*IMAGE_OCR))) { |
| if ($pid) { |
| kill('TERM', $pid) |
| and dbg("image: killed stale tesseract [$pid]"); |
| } |
| close IMAGE_OCR or 1; |
| } |
| |
| Mail::SpamAssassin::PerMsgStatus::leave_helper_run_mode($pms); |
| |
| unlink($tmp_file) if defined $tmp_file; |
| my $err_resp = (defined $err_file && -s $err_file) ? |
| do { open(my $efh, '<', $err_file); local $/; my $e = <$efh>; |
| close($efh); $e; } : ''; |
| unlink($err_file) if defined $err_file; |
| |
| if ($err) { |
| if ($err =~ /__brokenpipe__ignore__/) { |
| dbg("image: tesseract broken pipe, ignoring"); |
| } elsif ($timer->timed_out) { |
| dbg("image: tesseract timed out after ${secs}s"); |
| } else { |
| chomp(my $e = $err); |
| info("image: tesseract error: %s", $e); |
| } |
| return; |
| } |
| |
| return $resp; |
| } |
| |
| # Convert HEIF/HEIC bytes to PNG with heif-convert. Unlike tesseract, |
| # heif-convert writes to an output FILE rather than stdout, so we run it to a |
| # second temp file and read the PNG bytes back. Returns the PNG bytes, or undef |
| # if no converter is available or the conversion fails. |
| sub _heif_to_png { |
| my ($self, $pms, $dataref) = @_; |
| |
| my $conf = $pms->{conf} || $self->{main}->{conf}; |
| my $convert = $self->_heif_convert($conf); |
| if (!$convert) { |
| dbg("image: cannot OCR HEIF, heif-convert not available"); |
| return; |
| } |
| my $secs = $conf->{handler_time_limit} || 10; |
| |
| my ($in_file, $out_file, $err_file, $pid, $errno, $png); |
| |
| Mail::SpamAssassin::PerMsgStatus::enter_helper_run_mode($pms); |
| |
| my $timer = Mail::SpamAssassin::Timeout->new( |
| { secs => $secs, deadline => $pms->{master_deadline} }); |
| |
| my $err = $timer->run_and_catch(sub { |
| local $SIG{PIPE} = sub { die "__brokenpipe__ignore__\n" }; |
| |
| ($in_file, my $in_fh) = Mail::SpamAssassin::Util::secure_tmpfile(); |
| $in_file or die "failed to create a temporary file\n"; |
| print $in_fh $$dataref; |
| close($in_fh); |
| # heif-convert (libheif) infers the input format from the filename |
| # extension, and secure_tmpfile() produces extensionless names -- so give it |
| # a .heic name (a rename within the same secure tmpdir keeps our ownership). |
| my $heic_in = "$in_file.heic"; |
| rename($in_file, $heic_in) or die "cannot rename temp file: $!\n"; |
| $in_file = untaint_file_path($heic_in); |
| |
| # heif-convert picks the OUTPUT format from the extension too, so name it |
| # .png. We made the file with secure_tmpfile() to reserve the name, then |
| # rename to add the extension (heif-convert overwrites it). |
| ($out_file, my $out_fh) = Mail::SpamAssassin::Util::secure_tmpfile(); |
| $out_file or die "failed to create a temporary file\n"; |
| close($out_fh); |
| my $png_out = "$out_file.png"; |
| rename($out_file, $png_out) or die "cannot rename temp file: $!\n"; |
| $out_file = untaint_file_path($png_out); |
| |
| ($err_file, my $err_fh) = Mail::SpamAssassin::Util::secure_tmpfile(); |
| $err_file or die "failed to create a temporary file\n"; |
| close($err_fh); |
| $err_file = untaint_file_path($err_file); |
| |
| my @cmd = ($convert, '-q', '90', $in_file, $out_file); |
| $pid = Mail::SpamAssassin::Util::helper_app_pipe_open( |
| *HEIF_CONV, undef, ">$err_file", @cmd); |
| $pid or die "$!\n"; |
| |
| # Drain stdout (we don't need it) so the child can exit. |
| my ($inbuf, $nread); |
| while ($nread = read(HEIF_CONV, $inbuf, 8192)) { } |
| defined $nread or die "error reading from pipe: $!\n"; |
| |
| $errno = 0; |
| close HEIF_CONV or $errno = $!; |
| dbg("image: heif-convert [%s] finished: %s", |
| $pid, exit_status_str($?, $errno)); |
| }); |
| |
| if (defined(fileno(*HEIF_CONV))) { |
| kill('TERM', $pid) if $pid; |
| close HEIF_CONV or 1; |
| } |
| |
| Mail::SpamAssassin::PerMsgStatus::leave_helper_run_mode($pms); |
| |
| # Slurp the converted PNG if it was produced. |
| if (!$err && defined $out_file && -s $out_file) { |
| if (open(my $pfh, '<', $out_file)) { |
| binmode $pfh; |
| local $/; |
| $png = <$pfh>; |
| close($pfh); |
| } |
| } |
| |
| unlink($in_file) if defined $in_file; |
| unlink($out_file) if defined $out_file; |
| unlink($err_file) if defined $err_file; |
| |
| if ($err) { |
| if ($timer->timed_out) { |
| dbg("image: heif-convert timed out after ${secs}s"); |
| } else { |
| chomp(my $e = $err); |
| info("image: heif-convert error: %s", $e); |
| } |
| return; |
| } |
| |
| # Return a ref to the converted bytes (handle_image threads it on by ref). |
| return (defined $png && length $png) ? \$png : undef; |
| } |
| |
| =over 4 |
| |
| =item check_image_text_ratio(MIN_RATIO) |
| |
| Eval rule: true if the fraction of the message's body text that came from images |
| is greater than or equal to MIN_RATIO. The numerator is the number of words |
| OCR'd from all image parts; the denominator is the total number of words in the |
| rendered body (which, since OCR text is injected into the body, includes those |
| image words). A high fraction means the readable content is mostly text |
| rendered as a picture -- a classic image-spam evasion. MIN_RATIO is a fraction |
| from 0 to 1 and defaults to 0.5. |
| |
| =cut |
| |
| sub check_image_text_ratio { |
| my ($self, $pms, $body, $min_ratio) = @_; |
| |
| my $image_words = $pms->{plugins}{Image}{ocr_word_count} || 0; |
| return 0 unless $image_words; |
| |
| $min_ratio = 0.5 unless defined $min_ratio && $min_ratio ne ''; |
| |
| # $body is the rendered body text array passed to every body eval; since |
| # handle_image injects the OCR text via set_rendered, it already includes the |
| # image words -- so this is the total-body-word denominator. |
| my $body_words = 0; |
| $body_words += () = $_ =~ /\S+/g for @$body; |
| return 0 unless $body_words; |
| |
| my $ratio = $image_words / $body_words; |
| dbg("image: text ratio %.3f (image_words=%d body_words=%d min=%s)", |
| $ratio, $image_words, $body_words, $min_ratio); |
| return ($ratio >= $min_ratio) ? 1 : 0; |
| } |
| |
| =back |
| |
| =cut |
| |
| 1; |