ClamAV-Clamd

 view release on metacpan or  search on metacpan

t/60-verdict.t  view on Meta::CPAN

use strict;
use warnings;
use FindBin ();
use lib "$FindBin::Bin/lib";
use Test::More;
use File::Temp ();
use ClamAV::Clamd;
use FakeClamd;

plan skip_all => 'needs UNIX sockets' unless ClamAV::Clamd::_sun_path_max();

my $dir = File::Temp->newdir();

# THE RULE THIS FILE EXISTS FOR
#
# clamd answers OK for files it declined to scan. A boolean API reports
# "clean" for exactly the inputs an attacker constructs and passes every
# test anybody writes, because nobody writes the test where the answer is
# "I did not look". So: four states, and is_clean true for exactly one.

# --- the parser, driven from a peer that says whatever we want ----------
# Every shape below was captured from a real clamd (see the plan's phase
# 0, 3 and 4 results). Replaying them against a fake means the parser is
# tested everywhere, not only where ClamAV is installed.
my @SHAPES = (
    # reply                                                  state          signature                                reason
    ['fd[11]: OK',                                           'clean',       undef,                                   undef],
    ['stream: OK',                                           'clean',       undef,                                   undef],
    ['fd[11]: Eicar-Test-Signature FOUND',                   'infected',    'Eicar-Test-Signature',                  undef],
    ['stream: Win.Test.EICAR_HDB-1 FOUND',                   'infected',    'Win.Test.EICAR_HDB-1',                  undef],
    ['fd[11]: Heuristics.Limits.Exceeded.MaxFileSize FOUND', 'unscannable', 'Heuristics.Limits.Exceeded.MaxFileSize', 'MaxFileSize'],
    ['fd[11]: Heuristics.Limits.Exceeded.MaxRecursion FOUND','unscannable', 'Heuristics.Limits.Exceeded.MaxRecursion','MaxRecursion'],
    ['fd[11]: Heuristics.Limits.Exceeded.MaxFiles FOUND',    'unscannable', 'Heuristics.Limits.Exceeded.MaxFiles',    'MaxFiles'],
    ['fd[11]: Heuristics.Encrypted.Zip FOUND',               'unscannable', 'Heuristics.Encrypted.Zip',               'Encrypted'],
    ['fd[11]: Heuristics.Encrypted.DOC FOUND',               'unscannable', 'Heuristics.Encrypted.DOC',               'Encrypted'],
    ['INSTREAM size limit exceeded. ERROR',                  'unscannable', undef,                                    'StreamMaxLength'],
    ['/x: File path check failure: Permission denied. ERROR','error',       undef,                                    undef],
    ['UNKNOWN COMMAND',                                      'error',       undef,                                    undef],
    ['',                                                     'error',       undef,                                    undef],
    ['something nobody has ever seen',                       'error',       undef,                                    undef],

    # NOT unscannable: clamd looked and thinks the thing is bad. Only the
    # Limits.Exceeded and Encrypted families mean "I could not look".
    ['fd[11]: Heuristics.Phishing.Email.SpoofedDomain FOUND','infected',   'Heuristics.Phishing.Email.SpoofedDomain', undef],
    ['fd[11]: Heuristics.OLE2.ContainsMacros FOUND',         'infected',   'Heuristics.OLE2.ContainsMacros',          undef],

    # a path containing ": " must not eat the signature
    ['/tmp/a: b/c: Eicar-Test-Signature FOUND',              'infected',   'Eicar-Test-Signature',                    undef],
);

for my $case (@SHAPES) {
    my ($reply, $state, $sig, $reason) = @$case;
    my $srv = FakeClamd->new(mode => 'literal', literal => $reply);
    my $c   = ClamAV::Clamd->new(socket => $srv->path, reply_timeout => 10);
    my $v   = $c->scan('payload');

    my $label = length($reply) ? $reply : '(empty reply)';
    $label = substr($label, 0, 52);

    is $v->state,     $state,  "$label -> $state";
    is $v->signature, $sig,    "  signature" if defined $sig || $state eq 'clean';
    is $v->reason,    $reason, "  reason: " . ($reason // 'none')
        if defined $reason;

    # THE INVARIANT: is_clean is true for exactly one state, always.
    is !!$v->is_clean, !!($state eq 'clean'), "  is_clean matches, and only for clean";
    $srv->stop;
}

# --- the four predicates are mutually exclusive and total ---------------
for my $mode (
    ['fd[11]: OK',                                'is_clean'],
    ['fd[11]: Eicar-Test-Signature FOUND',        'is_infected'],
    ['fd[11]: Heuristics.Encrypted.Zip FOUND',    'is_unscannable'],
    ['UNKNOWN COMMAND',                           'is_error'],
) {
    my ($reply, $true_one) = @$mode;
    my $srv = FakeClamd->new(mode => 'literal', literal => $reply);
    my $c   = ClamAV::Clamd->new(socket => $srv->path, reply_timeout => 10);
    my $v   = $c->scan('x');

    my @on = grep { $v->$_ } qw(is_clean is_infected is_unscannable is_error);
    is_deeply \@on, [$true_one], "exactly one predicate is true for '$reply'";
    $srv->stop;
}

# --- a scan that never reached clamd is STILL a verdict -----------------
# Returning undef here would make
#     if ($clamd->scan($x)->is_clean) { ... }
# die on the one path where it matters most.
{
    my $c = ClamAV::Clamd->new(socket => '/tmp/cc-nothing-here.sock');
    my $v = $c->scan('x');
    isa_ok $v, 'ClamAV::Clamd::Verdict', 'a failed connect still yields a verdict';
    is $v->state, 'error', '  in the error state';
    ok !$v->is_clean, '  is_clean is safe to call and false';
    ok defined $v->error, '  and it carries the reason';
}

# --- overloading --------------------------------------------------------
# An object is always true, so `if ($clamd->scan($f))` would otherwise
# accept every infected file. bool is wired to is_clean so the most
# dangerous plausible misuse is correct instead of catastrophic.
{
    for my $case (['fd[11]: OK', 1], ['fd[11]: Eicar-Test-Signature FOUND', 0],



( run in 2.116 seconds using v1.01-cache-2.11-cpan-5c0b1e786e0 )