File Coverage

blib/lib/WebService/VirusTotal.pm
Criterion Covered Total %
statement 65 299 21.7
branch 10 174 5.7
condition 0 42 0.0
subroutine 17 33 51.5
pod 14 16 87.5
total 106 564 18.7


line stmt bran cond sub pod time code
1             #
2             # VirusTotal
3             #
4             # This package was insipred by Chistopher Frenz's perl script at:
5             # http://perlgems.blogspot.com.es/2012/05/using-virustotal-api-v20.html
6             #
7             # Package is Copyright (C) 2013, Michelle Sullivan (SORBS) , and Proofpoint Inc.
8             #
9              
10             package WebService::VirusTotal;
11              
12 3     3   78217 use 5.006;
  3         12  
13 3     3   18 use strict;
  3         5  
  3         69  
14 3     3   17 use warnings;
  3         16  
  3         91  
15 3     3   15 use Carp;
  3         5  
  3         255  
16              
17 3     3   21275 use LWP::UserAgent;
  3         244973  
  3         145  
18 3     3   3387 use JSON;
  3         54871  
  3         16  
19              
20 3     3   4233 use Digest::SHA qw(sha1_hex sha256_hex);
  3         10750  
  3         306  
21 3     3   21 use Digest::MD5 qw(md5_hex);
  3         7  
  3         141  
22 3     3   14 use List::Util qw(first);
  3         6  
  3         288  
23              
24 3     3   36 use base qw(Exporter);
  3         5  
  3         2049  
25              
26             our $ID = q$Id: VirusTotal.pm 3165 2014-01-07 12:31:37Z michelle $;
27             our $VERSION = sprintf("1.0.%d", q$Revision 3165$ =~ /(\d+)/g);
28             my $TESTSTRING = 'X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*';
29             my $INTERNALAGENT = sprintf("%s-MIS/0.%d", $ID =~ /Id:\s+(\w+)\.pm\s+(\d+)\s+/);
30              
31             #warn("[" . localtime(time()) . "] WebService::VirusTotal Revision: $VERSION (Agent: $INTERNALAGENT)\n");
32              
33             sub new
34             {
35 2     2 1 169 my $class = shift;
36 2         5 my $this = {};
37 2         5 my %arg = @_;
38              
39 2 50       11 $this->{DEBUG} = (exists $arg{debug} ? $arg{debug} : 0);
40 2         4 $this->{FNLEN} = 64;
41              
42 2         5 my %scanhash = ();
43              
44 2         4 $this->{'ua'} = undef;
45 2         6 $this->{'conn'} = undef;
46 2         5 $this->{'scanhash'} = \%scanhash;
47            
48 2         5 bless ($this, $class);
49              
50 2         9 return $this;
51             }
52              
53             sub init
54             {
55 1     1 0 4 my $self = shift;
56 1         5 $self->apiagent();
57 1         4 $self->scanapi();
58 1         3 $self->reportapi();
59 1         2 $self->apikey();
60             }
61              
62              
63             sub debug
64             {
65 0     0 1 0 my $self = shift;
66 0 0       0 if (@_) {$self->{DEBUG} = $_[0]};
  0         0  
67 0         0 return $self->{DEBUG};
68             }
69              
70             sub conn_proxy
71             {
72 0     0 1 0 my $self = shift;
73 0 0       0 if (@_) {$self->{PROXY} = $_[0]};
  0         0  
74 0         0 return $self->{PROXY};
75             }
76              
77             sub apiagent
78             {
79 1     1 1 2 my $self = shift;
80 1 50       5 if (@_) {$self->{AGENTSTRING} = $_[0]};
  0         0  
81 1 50       5 $self->{AGENTSTRING} = $INTERNALAGENT if (!defined $self->{AGENTSTRING});
82 1         3 return $self->{AGENTSTRING};
83             }
84              
85             sub conn_cache
86             {
87 0     0 1 0 my $self = shift;
88 0 0       0 if (@_) {$self->{CONNCACHE} = $_[0]};
  0         0  
89 0 0       0 $self->{CONNCACHE} = 0 if (!defined $self->{CONNCACHE});
90 0         0 return $self->{CONNCACHE};
91             }
92              
93             sub allowlong
94             {
95 0     0 1 0 my $self = shift;
96 0 0       0 if (@_)
97             {
98 0 0       0 if ($_[0])
99             {
100 0         0 $self->{FNLEN} = 256;
101             } else {
102 0         0 $self->{FNLEN} = 64;
103             }
104             }
105 0         0 return $self->{FNLEN};
106             }
107              
108             sub cache
109             {
110 0     0 1 0 my $self = shift;
111 0 0       0 if (@_) {$self->{CACHE} = $_[0]};
  0         0  
112             # if cache use is true, but the cache is not defined then init it
113 0 0 0     0 if (!defined $self->{CACHE} and $self->conn_cache())
114             {
115 0         0 $self->{CACHE} = $self->init_cache();
116             }
117             # return the cache object, this could be undef if caching is not defined.
118 0         0 return $self->{CACHE};
119             }
120            
121             sub init_cache
122             {
123 3     3   32632 use LWP::ConnCache;
  3         5057  
  3         11124  
124              
125 0     0 0 0 my $self = shift;
126 0 0       0 return $self->{CACHE} if defined $self->{CACHE};
127              
128 0         0 my $cache = LWP::ConnCache->new;
129 0         0 $cache->total_capacity($self->cache_limit());
130 0         0 return $cache;
131             }
132              
133             sub cache_check
134             {
135 0     0 1 0 my $self = shift;
136              
137             # if connection cache is not being used return true regardless
138 0 0       0 return 1 if (!$self->conn_cache());
139              
140             # if the cache is not initialised return an error
141 0 0       0 if (!defined $self->cache())
142             {
143 0         0 carp("Connection Cache is enabled but not intialised!");
144 0         0 return undef;
145             }
146              
147             # Prune dead entries
148 0         0 $self->cache->prune();
149 0         0 return 1;
150             }
151              
152             sub cache_limit
153             {
154 0     0 1 0 my $self = shift;
155 0 0       0 if (@_) {$self->{CACHELIMIT} = $_[0]};
  0         0  
156 0 0       0 $self->{CACHELIMIT} = 1 if (!defined $self->{CACHELIMIT});
157 0         0 return $self->{CACHELIMIT};
158             }
159              
160             sub reportapi
161             {
162 1     1 1 2 my $self = shift;
163 1 50       3 if (@_) {$self->{VTREPORTAPI} = $_[0]};
  0         0  
164 1 50       4 $self->{VTREPORTAPI} = "https://www.virustotal.com/vtapi/v2/file/report" if (!defined $self->{VTPREPORTAPI});
165 1         3 return $self->{VTREPORTAPI};
166             }
167              
168             sub scanapi
169             {
170 1     1 1 1 my $self = shift;
171 1 50       3 if (@_) {$self->{VTSCANAPI} = $_[0]};
  0         0  
172 1 50       5 $self->{VTSCANAPI} = "https://www.virustotal.com/vtapi/v2/file/scan" if (!defined $self->{VTPSCANAPI});
173 1         2 return $self->{VTSCANAPI};
174             }
175              
176             sub apikey
177             {
178 2     2 1 7 my $self = shift;
179 2 100       7 if (@_) {$self->{APIKEY} = $_[0]};
  1         9  
180 2 50       7 $self->{APIKEY} = undef if (!defined $self->{APIKEY});
181 2         8 return $self->{APIKEY};
182             }
183              
184             sub timeout
185             {
186 0     0 1   my $self = shift;
187 0 0         if (@_) { $self->{CONNTIMEOUT} = $_[0] };
  0            
188 0 0         $self->{CONNTIMEOUT} = 30 if (!defined $self->{CONNTIMEOUT});
189 0           return $self->{CONNTIMEOUT};
190             }
191              
192             # Public method for accessing VirusTotal
193             sub scan
194             {
195 0     0 1   my $self = shift;
196 0           my $file = shift;
197 0           my ($filename, $infected, $description) = (undef, undef, undef);
198 0           my $tmpfile = 0;
199 0           my $result = 0;
200 0 0         carp("Entered scan()...") if $self->debug();
201 0 0 0       if (length($file) > $self->allowlong() && $file !~ /\//)
202             {
203 0           $tmpfile++;
204             } else {
205 0 0 0       if ( -r $file && $file !~ /\.\./ )
206             {
207             # this is a filename and I can read it (and does not contain '..')
208 0           $filename = $file;
209             } else {
210 0           $tmpfile++;
211             }
212             }
213 0 0         if ($tmpfile)
214             {
215 0           $filename = "/tmp/nofilename-$$.tmp";
216 0           open FILE, ">$filename";
217 0           while (<$file>)
218             {
219 0           print FILE;
220             }
221 0           close FILE;
222             }
223              
224 0 0         carp("Filename for scanning is: $filename") if $self->debug();
225 0 0         if ($self->_connect())
226             {
227 0 0         carp("Connected to VT, scanning...") if $self->debug();
228             # We are connected we can proceed....
229             #
230 0           open FILE, "<$filename";
231 0           my $scankey = sha256_hex();
232 0           close FILE;
233            
234             #
235             # Check the internal checksums against the hash, if found we don't need to submit to VT
236             # If not found we need to call the private method _submit() and actually submit it
237             # If it is found we can call the private method _result() to get any results.
238 0 0         if (exists $self->{'scanhash'}->{$scankey})
239             {
240 0 0         carp("Found a hash.. checking for a result..") if $self->debug();
241 0           ($infected, $description) = $self->_result($scankey);
242             # return any result or undef if we have to wait for a result...
243 0 0         if (!defined $infected)
244             {
245             # an error occurred, could be that the file has not completed scanning
246 0           $description = "Please try later..";
247             }
248             } else {
249 0 0         carp("Did not find a hash.. submitting for a scan...") if $self->debug();
250             # scan key not found, so we need to submit it...
251 0           $result = $self->_submit($scankey, $filename);
252 0 0         if (defined $result)
253             {
254             # scan submission was successful, pause 15 seconds then check to see if there is a response
255 0 0         if ($result ne 1)
256             {
257 0 0         carp("Scan returned something other than done so pausing 15 seconds...") if $self->debug();
258 0           sleep 15;
259             }
260 0           ($infected, $description) = $self->_result($scankey);
261             # return any result or undef if we have to wait for a result...
262 0 0         if (!defined $infected)
263             {
264 0 0         carp("Result check was an error, tempfailing...") if $self->debug();
265             # an error occurred, could be that the file has not completed scanning
266 0           $description = "Please try later..";
267             }
268             }
269             }
270             } else {
271             # Not connected (and unable to connect) so return an error
272 0           carp("Not connected to VirusTotal API, check your configuration!");
273 0           $infected = undef;
274 0           $description = "Not connected to VirusTotal API, check your configuration!";
275             }
276 0 0         unlink($filename) if ($tmpfile);
277 0           return ($infected, $description);
278             }
279              
280             # Private method to test the connection state and if not connected/tested to try a connection.
281             sub _connect
282             {
283 0     0     my $self = shift;
284 0 0 0       if (!ref $self or !ref $self->{'scanhash'})
285             {
286 0           croak("You cannot call _connect() before calling new() (and you shouldn't do it!)");
287             }
288              
289 0 0 0       if (defined $self->{'valid_conn'} && $self->{'valid_conn'} && defined $self->{'ua'})
      0        
290             {
291             # We have previously connected
292 0           return $self->{'valid_conn'};
293             }
294              
295 0 0         if (!defined $self->{'valid_conn'})
296             {
297             # No valid connection tried, so setup LWP
298 0 0         if (!defined $self->{'ua'})
299             {
300 0           $self->{'ua'} = LWP::UserAgent->new(
301             ssl_opts => { verify_hostname => 1 },
302             agent => $self->apiagent(),
303             timeout => $self->timeout(),
304             conn_cache => $self->cache(),
305             );
306 0 0         if (defined $self->conn_proxy())
307             {
308 0           $self->{'ua'}->proxy('https', $self->conn_proxy());
309             }
310             }
311 0           $self->{'valid_conn'} = 0;
312             }
313             # A connection was previously tried and failed, or this is a new connection
314 0 0 0       if (!$self->{'valid_conn'} and $self->{'last_conn_check'} < (time() - 300))
315             {
316             # test the connection by sending the EICAR string..
317 0 0         carp("Connecting to " . $self->reportapi() . " for the EICAR test..") if $self->debug();
318 0           my $response = $self->{'ua'}->post( $self->reportapi(),
319             Content_Type => 'multipart/form-data',
320             Content => [
321             'apikey' => $self->apikey(),
322             'resource' => sha256_hex($TESTSTRING),
323             ],
324             );
325 0 0         if (!$response->is_success)
326             {
327 0 0         my $a = ( $response->status_line =~ /403 Forbidden/ ) ? " (Have you set your API key?)" : "";
328 0           carp("Unable to connect to VirusTotal using " . $self->scanapi() . " error: " . $response->status_line . "$a\n");
329             } else {
330 0           my $results=$response->content;
331 0 0         carp("Parsing test response: $results") if $self->debug();
332             # pulls the sha256 value out of the JSON response
333             # Note: there are many other values that could also be pulled out
334 0           my $json = JSON->new->allow_nonref;
335 0           my ($decjson, $sha, $respcode) = (undef, undef, undef);
336 0           eval {
337 0           $decjson = $json->decode($results);
338             };
339 0 0         if (defined $decjson)
340             {
341             # if json->decode() fails it will call croak, so we catch it and display the returned text
342 0           $sha = $decjson->{"sha256"};
343 0           $respcode = $decjson->{"response_code"};
344 0 0 0       if (defined $sha && $sha ne "")
345             {
346             # we were able to submit successfully so we can set valid_conn to true
347 0           $self->{'scanhash'}->{'test'}->{'key'} = $sha;
348 0           $self->{'scanhash'}->{'test'}->{'submitted'} = sprintf("%d", $decjson->{"scanid"} =~ /^[1234567890abcdef]+-(\d+)$/);
349 0           $self->{'scanhash'}->{'test'}->{'last_checked'} = sprintf("%d", $decjson->{"scanid"} =~ /^[1234567890abcdef]+-(\d+)$/);
350 0           $self->{'scanhash'}->{'test'}->{'infected'} = $decjson->{"positives"};
351 0     0     $self->{'scanhash'}->{'test'}->{'result'} = first { $_->{detected} } values %{ $decjson->{scans} };
  0            
  0            
352 0           $self->{'valid_conn'} = 1;
353 0 0         carp("Validated connection...") if $self->debug();
354             } else {
355 0           carp("Unable to parse test, VirusTotal responded with: $results");
356             }
357             }
358             }
359 0           $self->{'last_conn_check'} = time();
360             }
361 0           return $self->{'valid_conn'};
362             }
363              
364             # Private method, will send the request to VirusTotal
365             sub _submit
366             {
367             #Code to submit a file to Virus Total
368 0     0     my $self = shift;
369 0           my $res = undef;
370 0 0         carp("Entered _submit()") if $self->debug();
371 0 0         croak ("You can't call this directly! Use the scan() method!") if (!ref $self);
372 0 0         croak ("You cannot call _submit() before calling new() (and you shouldn't do it!") if (!ref $self->{'scanhash'});
373              
374 0 0 0       if (!defined $self->{'valid_conn'} && !$self->{'valid_conn'})
375             {
376 0           carp("Not connected to VirusTotal, please check your connection before calling _submit()!");
377 0           return $res;
378             }
379              
380 0           my $scankey = shift; # This is our internally generated scan key to be used to lookup the result key
381 0           my $file = shift; # This is the file name and location of the file to check
382              
383 0 0         START: carp("Sending file ($file)..") if $self->debug();
384 0           my $response = $self->{'ua'}->post(
385             $self->scanapi,
386             Content_Type => 'multipart/form-data',
387             Content => [
388             'apikey' => $self->apikey(),
389             'file' => [$file]
390             ]
391             );
392 0 0         if (!$response->is_success)
393             {
394 0           carp("Unable to post to '" . $self->scanapi() . "' error: " . $response->status_line . "\n");
395 0           return $res;
396             }
397 0           my $results=$response->content;
398            
399 0 0         carp("Got response: $results") if $self->debug();
400             #pulls the sha256 value out of the JSON response
401             #Note: there are many other values that could also be pulled out
402 0           my $json = JSON->new->allow_nonref;
403 0           my ($decjson, $sha, $respcode) = (undef, undef, undef);
404 0           eval {
405 0           $decjson = $json->decode($results);
406             };
407 0 0         if (defined $decjson)
408             {
409             # if json->decode() fails it will call croak, so we catch it and display the returned text
410 0           $sha = $decjson->{"sha256"};
411 0           $respcode = $decjson->{"response_code"};
412 0 0         if (defined $respcode)
413             {
414 0 0         carp("Got response code $respcode") if $self->debug();
415 0 0 0       if ($respcode eq "1")
    0          
    0          
416             {
417             # we were able to submit successfully and we got a report embedded
418 0           $self->{'scanhash'}->{$scankey}->{'key'} = $sha;
419 0           $self->{'scanhash'}->{$scankey}->{'submitted'} = sprintf("%d", $decjson->{"scanid"} =~ /^[1234567890abcdef]+-(\d+)$/);
420 0           $self->{'scanhash'}->{$scankey}->{'last_checked'} = sprintf("%d", $decjson->{"scanid"} =~ /^[1234567890abcdef]+-(\d+)$/);
421 0           $self->{'scanhash'}->{$scankey}->{'infected'} = $decjson->{"positives"};
422 0     0     $self->{'scanhash'}->{$scankey}->{'result'} = first { $_->{detected} } values %{ $decjson->{scans} };
  0            
  0            
423 0           $self->{'valid_conn'} = 1;
424             } elsif ($respcode eq "-2" or $respcode eq "0") {
425             # we were able to submit successfully and we got a response indicating queued
426 0           $self->{'scanhash'}->{$scankey}->{'key'} = $sha;
427 0           $self->{'scanhash'}->{$scankey}->{'submitted'} = time();
428 0           $self->{'scanhash'}->{$scankey}->{'last_checked'} = 0;
429 0           $self->{'scanhash'}->{$scankey}->{'infected'} = undef;
430 0           $self->{'scanhash'}->{$scankey}->{'result'} = $decjson->{"verbose_msg"};
431 0           $self->{'valid_conn'} = 1;
432             } elsif ($respcode eq "-1") {
433 0           carp("Transient Error occured, restarting...\n");
434 0 0         carp("Got response code -1 (so restarting..)") if $self->debug();
435 0           goto START;
436             } else {
437 0 0         carp("Got response code $respcode (" . $decjson->{"verbose_msg"} . ")") if $self->debug();
438 0           $self->{'scanhash'}->{$scankey}->{'result'} = $decjson->{"verbose_msg"};
439             }
440             } else {
441 0           carp("Unable to parse $scankey, VirusTotal responded with: $results");
442             }
443             } else {
444 0           carp("Unable to parse $scankey, VirusTotal responded with: $results");
445 0           carp("JSON decoder returned: $decjson [$@]");
446             }
447 0           return $respcode;
448             }
449              
450             # Private method, will get the request from VirusTotal
451             sub _result
452             {
453             #Code to retrieve a result from VirusTotal
454 0     0     my $self = shift;
455 0 0         carp("Entered _result()") if $self->debug();
456 0 0         croak ("You can't call this directly! Use the scan() method!") if (!ref $self);
457 0 0         croak ("You cannot call _result() before calling new() (and you shouldn't do it!") if (!ref $self->{'scanhash'});
458              
459 0           my $scankey = shift; # This is our internally generated scan key to be used to lookup the result key
460              
461 0 0 0       if (!defined $self->{'valid_conn'} && !$self->{'valid_conn'})
462             {
463 0           carp("Not connected to VirusTotal, please check your connection before calling _result()!");
464 0           return undef;
465             }
466              
467 0 0 0       if (!exists $self->{'scanhash'}->{$scankey} || !defined $self->{'scanhash'}->{$scankey}->{'key'})
468             {
469 0           carp("Attempted to retrieve a result for a key that doesn't exist!");
470 0           return undef;
471             }
472              
473             # Check to see if we checked in the last 5 minutes. If we did return the same result.
474 0 0 0       unless ($self->{'scanhash'}->{$scankey}->{'last_checked'} && $self->{'scanhash'}->{$scankey}->{'last_checked'} > (time() - 300))
475             {
476             # Code to retrieve the results that pertain to a submitted file by hash value
477             # FIXME: Original code had neither content_type or content .. are they needed (probably not, but should we include) for readability?
478 0 0         RESTART: carp("Sending filehash ($scankey)..") if $self->debug();
479 0           my $response = $self->{'ua'}->post(
480             $self->reportapi(),
481             Content_Type => 'multipart/form-data',
482             Content => [
483             'apikey' => $self->apikey(),
484             'resource' => $scankey
485             ]
486             );
487            
488 0 0         if (!$response->is_success)
489             {
490 0           carp("Unable to post to '" . $self->reportapi() . "' error: " . $response->status_line . "\n");
491 0           return (undef, undef);
492             }
493 0           my $results=$response->content;
494 0 0         carp("Got response: $results") if $self->debug();
495            
496             # pulls the sha256 value out of the JSON response
497             # Note: there are many other values that could also be pulled out
498 0           my $json = JSON->new->allow_nonref;
499 0           my ($decjson, $sha, $respcode) = (undef, undef, undef);
500 0           eval {
501 0           $decjson = $json->decode($results);
502             };
503 0 0         if (defined $decjson)
504             {
505             # if json->decode() fails it will call croak, so we catch it and display the returned text
506 0           $sha = $decjson->{"sha256"};
507 0           $respcode = $decjson->{"response_code"};
508 0 0         if (defined $respcode)
509             {
510 0 0 0       if ($respcode eq "1")
    0          
    0          
511             {
512             # we were able to submit successfully so we can set valid_conn to true
513 0           $self->{'scanhash'}->{$scankey}->{'key'} = $sha;
514 0           $self->{'scanhash'}->{$scankey}->{'submitted'} = sprintf("%d", $decjson->{"scanid"} =~ /^[1234567890abcdef]+-(\d+)$/);
515 0           $self->{'scanhash'}->{$scankey}->{'last_checked'} = sprintf("%d", $decjson->{"scanid"} =~ /^[1234567890abcdef]+-(\d+)$/);
516 0           $self->{'scanhash'}->{$scankey}->{'infected'} = $decjson->{"positives"};
517 0     0     $self->{'scanhash'}->{$scankey}->{'result'} = first { $_->{detected} } values %{ $decjson->{scans} };
  0            
  0            
518 0           $self->{'valid_conn'} = 1;
519 0 0         carp("Got response code $respcode") if $self->debug();
520             } elsif ($respcode eq "-2" or $respcode eq "0") {
521             # we were able to submit successfully and we got a response indicating queued
522 0           $self->{'scanhash'}->{$scankey}->{'key'} = $sha;
523 0           $self->{'scanhash'}->{$scankey}->{'submitted'} = time();
524 0           $self->{'scanhash'}->{$scankey}->{'last_checked'} = 0;
525 0           $self->{'scanhash'}->{$scankey}->{'infected'} = undef;
526 0           $self->{'scanhash'}->{$scankey}->{'result'} = $decjson->{"verbose_msg"};
527 0           $self->{'valid_conn'} = 1;
528 0 0         carp("Got response code $respcode") if $self->debug();
529             } elsif ($respcode eq "-1") {
530 0           carp("Transient Error occured, restarting...\n");
531 0 0         carp("Got response code $respcode (transient error, restarting)") if $self->debug();
532 0           goto RESTART;
533             } else {
534 0 0         carp("Got unknown response code $respcode") if $self->debug();
535 0           $self->{'scanhash'}->{$scankey}->{'result'} = $decjson->{"verbose_msg"};
536             }
537             } else {
538 0           carp("Unable to parse $scankey, VirusTotal responded with: $results");
539             }
540             } else {
541 0           carp("Unable to parse $scankey, VirusTotal responded with: $results");
542             }
543             }
544 0           return ($self->{'scanhash'}->{$scankey}->{'infected'}, $self->{'scanhash'}->{$scankey}->{'result'}->{'result'});
545             }
546              
547             1;
548              
549             __END__