Files
clamtk/lib/Analysis.pm
2021-02-19 04:39:14 -06:00

816 lines
24 KiB
Perl

# ClamTk, copyright (C) 2004-2021 Dave M
#
# This file is part of ClamTk
# (https://gitlab.com/dave_m/clamtk/).
#
# ClamTk is free software; you can redistribute it and/or modify it
# under the terms of either:
#
# a) the GNU General Public License as published by the Free Software
# Foundation; either version 1, or (at your option) any later version, or
#
# b) the "Artistic License".
package ClamTk::Analysis;
use Glib 'TRUE', 'FALSE';
# use strict;
# use warnings;
$| = 1;
use LWP::UserAgent;
use LWP::Protocol::https;
use MIME::Base64 'decode_base64';
use Digest::SHA 'sha256_hex';
use File::Basename 'basename';
use File::Copy 'move';
use Text::CSV;
use JSON;
use Encode 'encode';
use POSIX 'locale_h';
use Locale::gettext;
binmode STDIN, ":encoding(UTF-8)";
my $store; # Liststore results
my $model; # ListStore for combobox
my $combobox; # ComboBox for previous analysis
my $nb; # Notebook
my $filename = ''; # name of file analyzed
my $bar; # InfoBar
my $window; # Dialog; global for queue_draw;
my $submitted = 0; # Boolean value to keep track if file
# has been submitted or not
my $from_scan;
sub show_window {
( undef, $from_scan, $parent ) = @_;
$window
= Gtk3::Dialog->new( undef, $parent,
[ qw| modal destroy-with-parent use-header-bar | ],
);
$window->signal_connect(
destroy => sub {
$window->destroy;
1;
}
);
$window->set_border_width( 5 );
$window->set_default_size( 450, 250 );
$window->set_position( 'mouse' );
my $hb = Gtk3::HeaderBar->new;
$hb->set_title( _( 'Analysis' ) );
$hb->set_show_close_button( TRUE );
$hb->set_decoration_layout( 'menu:close' );
my $images_dir = ClamTk::App->get_path( 'images' );
my $pixbuf
= Gtk3::Gdk::Pixbuf->new_from_file_at_size( "$images_dir/clamtk.png",
24, 24 );
my $image = Gtk3::Image->new;
$image->set_from_pixbuf( $pixbuf );
my $button = Gtk3::ToolButton->new( $image, '' );
$button->set_sensitive( FALSE );
$button->set_tooltip_text( _( 'ClamTk Virus Scanner' ) );
$hb->pack_start( $button );
$window->set_titlebar( $hb );
my $help_message = _(
"This section allows you to check the status of a file from Virustotal. If a file has been previously submitted, you will see the results of the scans from dozens of antivirus and security vendors. This allows you to make a more informed decision about the safety of a file.
If the file has never been submitted to Virustotal, you can submit it yourself. When you resubmit the file minutes later, you should see the results.
Please note you should never submit sensitive files to Virustotal. Any uploaded file can be viewed by Virustotal customers. Do not submit anything with personal information or passwords.
https://www.virustotal.com/gui/home/search"
);
my $help_btn = Gtk3::ToolButton->new();
my $use_image = ClamTk::Icons->get_image( 'system-help' );
$help_btn->set_label( _( 'Help' ) );
$help_btn->set_icon_name( $use_image );
$help_btn->set_is_important( TRUE );
$help_btn->set_tooltip_text( _( 'What is this?' ) );
$help_btn->signal_connect( clicked => sub { popup( $help_message ) } );
$hb->pack_start( $help_btn );
my $box = Gtk3::Box->new( 'vertical', 5 );
$box->set_homogeneous( FALSE );
$window->get_content_area->add( $box );
$nb = Gtk3::Notebook->new;
$box->pack_start( $nb, TRUE, TRUE, 0 );
#<<<
# Add initial page (select file, etc)
$nb->insert_page(
page_one(),
Gtk3::Label->new( _( 'File Analysis' ) ),
0,
);
# Add results page
$nb->insert_page(
page_two(),
Gtk3::Label->new( _( 'Results' ) ),
1,
);
#>>>
$bar = Gtk3::InfoBar->new;
$box->pack_start( $bar, FALSE, FALSE, 0 );
$bar->set_message_type( 'other' );
# $bar->add_button( 'gtk-close', -7 );
$bar->add_button( _( 'Close' ), -7 );
$bar->signal_connect(
response => sub {
$submitted = 0;
$window->destroy;
}
);
$window->show_all;
$window->run;
$window->destroy;
1;
}
sub page_one {
# Analysis page
my $box = Gtk3::Box->new( 'vertical', 5 );
$box->set_homogeneous( FALSE );
my $separator = Gtk3::Separator->new( 'horizontal' );
$box->pack_start( analysis_frame_one(), FALSE, FALSE, 5 );
$box->pack_start( $separator, FALSE, FALSE, 5 );
$box->pack_start( analysis_frame_two(), FALSE, FALSE, 5 );
return $box;
}
sub page_two {
# Results page
my $box = Gtk3::VBox->new( FALSE, 0 );
my $sw = Gtk3::ScrolledWindow->new( undef, undef );
$sw->set_policy( 'never', 'automatic' );
$box->pack_start( $sw, TRUE, TRUE, 0 );
use constant VENDOR => 0;
use constant RESULT => 1;
use constant DATE => 2;
#<<<
$store = Gtk3::ListStore->new(
# VENDOR
'Glib::String',
# RESULT
'Glib::String',
# DATE
'Glib::String',
);
my $tree = Gtk3::TreeView->new_with_model( $store );
$tree->set_rules_hint( TRUE );
$sw->add( $tree );
my $renderer = Gtk3::CellRendererText->new;
my $column
= Gtk3::TreeViewColumn->new_with_attributes(
_( 'Vendor' ),
$renderer,
text => VENDOR,
);
$column->set_expand( TRUE );
$column->set_sort_column_id( VENDOR );
$tree->append_column( $column );
$column = Gtk3::TreeViewColumn->new_with_attributes(
_( 'Date' ),
$renderer,
text => DATE,
);
$column->set_sort_column_id( DATE );
$column->set_expand( TRUE );
$tree->append_column( $column );
$column = Gtk3::TreeViewColumn->new_with_attributes(
_( 'Result' ),
$renderer,
text => RESULT,
);
$column->set_expand( TRUE );
$column->set_sort_column_id( RESULT );
$tree->append_column( $column );
#>>>
my $toolbar = Gtk3::Toolbar->new;
$box->pack_start( $toolbar, FALSE, FALSE, 3 );
$toolbar->set_style( 'both-horiz' );
my $separator = Gtk3::SeparatorToolItem->new;
$separator->set_draw( FALSE );
$separator->set_expand( TRUE );
$toolbar->insert( $separator, -1 );
my $button = Gtk3::ToolButton->new();
$button->set_icon_name( 'document-save-as' );
$button->set_label( _( 'Save results' ) );
# $button->set_tooltip_text( _( 'Save results' ) );
$toolbar->insert( $button, -1 );
$button->signal_connect(
clicked => sub {
return unless ( $store->iter_n_children );
save_file();
}
);
return $box;
}
sub analysis_frame_one {
my $box = Gtk3::Box->new( 'vertical', 5 );
$box->set_homogeneous( FALSE );
my $label
= Gtk3::Label->new( _( "Check or recheck a file's reputation" ) );
$label->set_alignment( 0.0, 0.5 );
$box->pack_start( $label, FALSE, FALSE, 5 );
my $grid = Gtk3::Grid->new();
$box->pack_start( $grid, FALSE, FALSE, 5 );
$grid->set_column_spacing( 10 );
$grid->set_column_homogeneous( FALSE );
$grid->set_row_spacing( 10 );
$grid->set_row_homogeneous( TRUE );
#<<<
# Declaring this now for setting sensitive/insensitive
my $button = Gtk3::ToolButton->new();
my $use_image = ClamTk::Icons->get_image('edit-select');
$button->set_icon_name($use_image);
my $select_button
= Gtk3::FileChooserButton->new(
_( 'Select a file' ),
'open',
);
# If we've arrived from scanning results, set the file:
if( $from_scan ) {
$select_button->set_filename( $from_scan );
}
$button->set_sensitive( TRUE );
$select_button->set_hexpand(TRUE);
$select_button->set_current_folder(
ClamTk::App->get_path( 'directory' )
);
$grid->attach( $select_button, 0, 0, 1, 1 );
#>>>
my $separator = Gtk3::SeparatorToolItem->new;
$separator->set_draw( FALSE );
$grid->attach( $separator, 1, 0, 1, 1 );
$grid->attach( $button, 2, 0, 1, 1 );
$button->set_tooltip_text( _( 'Submit file for analysis' ) );
$button->signal_connect(
clicked => sub {
$filename = $select_button->get_filename;
return unless -e $filename;
$submitted++;
# VT size limit using the API is 32MB
# https://www.virustotal.com/en/faq/
my $size = -s $filename;
my $mb = $size / ( 1024 * 1024 );
if ( $mb > 32 ) {
warn "filesize too large - must be smaller than 32MB\n";
popup( _( 'Uploaded files must be smaller than 32MB' ) );
return;
}
# Gtk3::main_iteration while Gtk3::events_pending;
my ( $vt_results, $new_window, $is_error )
= check_for_existing( $filename );
# Information exists on this file; show results
if ( $new_window ) {
$nb->show_all;
$nb->set_current_page( 1 );
#} elsif ( $new_window && !$is_error ) {
} elsif ( $vt_results == 0 ) {
# No information exists; offer to submit file
my $confirm = popup(
_( 'No information exists for this file.' . ' '
. 'Press OK to submit this file for analysis.'
),
1
);
if ( $confirm ) {
$submitted++;
submit_new();
return;
}
} else {
popup( _( 'Unable to submit file: try again later' ) );
return;
}
}
);
return $box;
}
sub analysis_frame_two {
my $box = Gtk3::Box->new( 'vertical', 5 );
$box->set_homogeneous( FALSE );
my $label = Gtk3::Label->new( _( 'View or delete previous results' ) );
$label->set_alignment( 0.0, 0.5 );
$box->pack_start( $label, FALSE, FALSE, 0 );
my $grid = Gtk3::Grid->new();
$box->pack_start( $grid, FALSE, FALSE, 5 );
$grid->set_column_spacing( 10 );
$grid->set_column_homogeneous( FALSE );
$grid->set_row_spacing( 10 );
$grid->set_row_homogeneous( TRUE );
$model = Gtk3::ListStore->new( 'Glib::String' );
$combobox = Gtk3::ComboBox->new_with_model( $model );
$combobox->set_hexpand( TRUE );
$grid->attach( $combobox, 0, 0, 1, 1 );
my $render = Gtk3::CellRendererText->new;
$combobox->pack_start( $render, TRUE );
$combobox->add_attribute( $render, text => 0 );
read_files();
my $separator = Gtk3::SeparatorToolItem->new;
$separator->set_draw( FALSE );
$grid->attach( $separator, 1, 0, 1, 1 );
my $button = Gtk3::ToolButton->new();
$button->set_icon_name( 'text-x-preview' );
$button->set_tooltip_text( _( 'View file results' ) );
$grid->attach( $button, 2, 0, 1, 1 );
$button->signal_connect(
clicked => sub {
# my $file = $combobox->get_active_text;
my $iter = $combobox->get_active_iter();
return unless ( $model->iter_is_valid( $iter ) );
my $file = $model->get_value( $iter, 0 );
return unless ( $file );
my $file_to_use = '';
my $files = read_files();
# Written so as to not choke older Perls
for my $key ( sort @$files ) {
if ( $key->{ basefilepath } eq $file ) {
$file_to_use = $key->{ readpath };
}
}
# For some reason, we'll use csv files
my $csv = Text::CSV->new( { binary => 1, eol => "\n" } )
or do {
warn "Unable to begin Text::CSV: $!\n";
return;
};
open( my $f, '<:encoding(utf8)', $file_to_use ) or do {
warn "Unable to opening VT CSV file: $!\n";
# popup( _( 'Error opening VT CSV file' ) );
return;
};
# File, Hash, Date
my $fields;
my $counter = 0;
while ( my $row = <$f> ) {
chomp( $row );
next if ( $row =~ /^#/ );
next if ( $row =~ /^\s*$/ );
if ( $csv->parse( $row ) ) {
$fields->[ $counter ] = [ $csv->fields() ];
$counter++;
}
}
close( $f );
$store->clear;
#<<<
# Written so as to not choke older Perls
for my $v ( sort @{ $fields } ) {
my $iter = $store->append;
$store->set(
$iter,
VENDOR, $v->[0],
RESULT, $v->[1],
DATE, $v->[2],
);
#>>>
}
$nb->show_all;
$nb->set_current_page( 1 );
}
);
$button = Gtk3::ToolButton->new();
$button->set_icon_name( 'edit-delete' );
$button->set_tooltip_text( _( 'Delete file results' ) );
$grid->attach( $button, 3, 0, 1, 1 );
$button->signal_connect(
clicked => sub {
my $file = $combobox->get_active_text;
return unless ( $file );
my $file_to_use = '';
my $active_text = $combobox->get_active_text;
my $active_iter = $combobox->get_active;
my $files = read_files();
# This has to be written a certain way so
# that older Perls don't $choke
for my $key ( sort @{ $files } ) {
if ( $key->{ basefilepath } eq $file ) {
$file_to_use = $key->{ readpath };
if ( -e $file_to_use ) {
unlink( $file_to_use ) or do {
warn "Unable to delete VT $file_to_use: $!\n";
return;
};
$combobox->set_active( -1 );
}
read_files();
# we're done!
last;
}
}
}
);
return $box;
}
sub read_files {
my $submit_dir = ClamTk::App->get_path( 'submit' );
my $results;
$model->clear;
my $arr_count = 0;
# Grab all files
for my $f ( glob "$submit_dir/*" ) {
my $basename = basename( $f );
# Might be other garbage in the folder; just
# look for ones that look like hashes
next unless ( length $basename == 64 );
$results->[ $arr_count ]->{ readpath } = $f;
$results->[ $arr_count ]->{ hash } = basename( $f );
open( my $t, '<:encoding(utf8)', $f ) or do {
warn "Unable to open analysis file >$f<: $!\n";
next;
};
while ( <$t> ) {
chomp;
if ( /^# File (.*?)$/ ) {
$results->[ $arr_count ]->{ filepath } = $1;
$results->[ $arr_count ]->{ basefilepath }
= basename( $1 );
$model->set( $model->append, 0,
basename( $results->[ $arr_count ]->{ filepath } ) );
last;
}
}
close( $t );
$arr_count++;
}
return $results;
}
sub check_for_existing {
# First, set the infobar so the
# user knows something is happening:
set_infobar_mode( _( 'Please wait...' ) );
$window->queue_draw;
my $file = shift;
my $url = 'https://www.virustotal.com/vtapi/v2/file/report';
my $local_tk_version = ClamTk::App->get_TK_version();
my $hash = get_hash( $file );
my $ua = add_ua_proxy();
#<<<
my @req = (
$url,
[
resource => $hash,
apikey => getapi(),
]
);
#>>>
set_infobar_mode( ' ' );
# $new_window = switch to results tab
my $new_window = TRUE;
# $is_error = connection (or other) issue
my $is_error = FALSE;
my $response = $ua->post( @req );
# warn "is_err = >", $response->is_error, "<\n";
my $data;
if ( $response->is_success ) {
my $json = JSON->new->utf8->allow_nonref;
eval { $data = $json->decode( $response->decoded_content ); };
if ( $@ ) {
warn "error reading/decoding json: $@\n";
$new_window = FALSE;
$is_error = TRUE;
$data->{ response_code } = -4;
return ( $data->{ response_code }, $new_window, $is_error );
}
# warn "response_code from submission >", $data->{ response_code },
# "<\n";
# Response codes:
# 0 = not present in dataset
# -2 = queued for analysis
# 1 = present and can be retrieved
if ( $data->{ response_code } == 1 ) {
# $return = $data->{ positives } . ' / ' . $data->{ total };
for my $mark ( $data->{ scans } ) {
while ( my ( $vendor, $v ) = each %$mark ) {
my $iter = $store->append;
#<<<
$store->set(
$iter,
VENDOR, $vendor,
RESULT,
( $v->{ result } )
? $v->{ result }
: '---',
DATE, $v->{ update },
);
#>>>
}
}
} elsif ( $data->{ response_code } == 0 ) {
# $return = _( 'No information on this file' );
$new_window = FALSE;
$is_error = FALSE;
} elsif ( $data->{ response_code } == -2 ) {
# $return = _( 'File is pending analysis' );
$new_window = FALSE;
$is_error = FALSE;
} else {
# $return = _( 'An unknown error occurred' );
$data->{ response_code } = -4;
$new_window = FALSE;
$is_error = TRUE;
}
} else {
$data->{ response_code } = -4;
$new_window = FALSE;
$is_error = TRUE;
warn "Unable to connect in submission: ", $response->status_line,
"\n";
}
set_infobar_mode( ' ' );
return ( $data->{ response_code }, $new_window, $is_error );
}
sub submit_new {
# First, set the infobar so the user knows
# something is happening:
set_infobar_mode( _( 'Please wait...' ) );
$window->queue_draw;
my $url = 'https://www.virustotal.com/vtapi/v2/file/scan';
my $ua = add_ua_proxy();
my $local_tk_version = ClamTk::App->get_TK_version();
my $u_filename = encode( 'UTF-8', $filename );
#<<<
my $response = $ua->post(
$url,
Content_Type => 'multipart/form-data',
Content => [
apikey => getapi(),
file => [ $u_filename ]
#file => [ $filename ]
],
);
#>>>
set_infobar_mode( ' ' );
$bar->show_all;
$window->queue_draw;
if ( $response->is_success ) {
# Save off the results; might use them later
my $file = ClamTk::App->get_path( 'virustotal_links' );
my $hash = get_hash( $filename );
my $json_text = $response->decoded_content;
my $data = $response->as_string;
my $json_hash = decode_json( $json_text );
open( my $f, '>>:encoding(UTF-8)', $file );
# or warn "Unable to save virustotal results: $!\n";
my $csv = Text::CSV->new( { binary => 1, eol => "\n" } );
my $ref;
$ref->[ 0 ] = [ $filename, $hash, $json_hash->{ permalink } ];
$csv->print( $f, $_ ) for ( @$ref );
close( $f );
popup( _( 'File successfully submitted for analysis.' ) );
} else {
popup( _( 'Unable to submit file: try again later' ) );
}
}
sub getapi {
#<<<
return decode_base64(
'MDMwNmU3MT'
. 'ZjYjQxNTMz'
. 'OWFkZDQ5ND'
. 'JkOTg4ZWVm'
. 'MDU3MjlmND'
. 'AxYmM0NjIw'
. 'MjZmNDQ2OD'
. 'gzYjcxNmIy'
. 'NTAxZg=='
);
#>>>
}
sub get_hash {
my ( $file ) = shift;
my $slurp = do {
local $/ = undef;
open( my $f, '<', $file ) or do {
warn "Unable to open >$file< for hashing: $!\n";
return;
};
binmode( $f );
<$f>;
};
return sha256_hex( $slurp );
}
sub save_file {
# VENDOR, RESULT, DATE
# temporary filename
my $hash = get_hash( $filename );
my $file = ClamTk::App->get_path( 'submit' ) . '/' . $hash;
my $ref;
my $count = 0;
$store->foreach(
sub {
my ( $liststore, $path, $iter ) = @_;
my ( $vendor, $result, $date )
= $liststore->get( $iter, 0, 1, 2 );
$ref->[ $count ] = [ $vendor, $result, $date ];
$count++;
return FALSE;
}
);
# For some reason, we'll use csv files
my $csv = Text::CSV->new( { binary => 1, eol => "\n" } )
or do {
popup( _( 'Unable to save file' ) );
return;
};
open( my $f, '>:encoding(utf8)', $file ) or do {
popup( _( 'Unable to save file' ) );
return;
};
print $f '# File', ' ', $filename, "\n";
print $f '# Hash', ' ', $hash, "\n";
print $f '# Date', ' ', scalar localtime, "\n\n";
$csv->print( $f, $_ ) for ( @$ref );
close( $f );
popup( _( 'File has been saved' ) );
read_files();
return TRUE;
}
sub popup {
my ( $message, $option ) = @_;
my $dialog = Gtk3::MessageDialog->new(
undef, # no parent
[ qw| modal destroy-with-parent | ],
'info',
$option ? 'ok-cancel' : 'close',
$message,
);
if ( 'ok' eq $dialog->run ) {
$dialog->destroy;
return TRUE;
}
$dialog->destroy;
return FALSE;
}
sub set_infobar_mode {
my $text = shift;
for my $c ( $bar->get_content_area->get_children ) {
if ( $c->isa( 'Gtk3::Label' ) ) {
$c->set_text( $text );
$bar->show_all;
$window->queue_draw;
return;
}
}
# We don't have a Label, so make one:
my $label = Gtk3::Label->new( $text );
$label->set_use_markup( TRUE );
$bar->get_content_area->add( $label );
$bar->show_all;
$window->queue_draw;
return;
}
sub add_ua_proxy {
my $agent = LWP::UserAgent->new( ssl_opts => { verify_hostname => 1 } );
my $local_tk_version = ClamTk::App->get_TK_version();
$agent->agent( "ClamTk/$local_tk_version" );
$agent->timeout( 60 );
$agent->protocols_allowed( [ 'http', 'https' ] );
if ( ClamTk::Prefs->get_preference( 'HTTPProxy' ) ) {
if ( ClamTk::Prefs->get_preference( 'HTTPProxy' ) == 1 ) {
$agent->env_proxy;
} elsif ( ClamTk::Prefs->get_preference( 'HTTPProxy' ) == 2 ) {
my $path = ClamTk::App->get_path( 'db' );
$path .= '/local.conf';
my ( $url, $port );
if ( -e $path ) {
if ( open( my $FH, '<', $path ) ) {
while ( <$FH> ) {
if ( /HTTPProxyServer\s+(.*?)$/ ) {
$url = $1;
}
last if ( !$url );
if ( /HTTPProxyPort\s+(\d+)$/ ) {
$port = $1;
}
}
close( $FH );
$agent->proxy( http => "$url:$port" );
$agent->proxy( https => "$url:$port" );
$ENV{ HTTPS_PROXY } = "$url:$port";
$ENV{ HTTP_PROXY } = "$url:$port";
$ENV{ PERL_LWP_SSL_VERIFY_HOSTNAME } = 0;
$ENV{ HTTPS_DEBUG } = 1;
}
}
}
}
return $agent;
}
sub button_test {
# So, this is a test to determine if set_filename works,
# which is tested by seeing if we can get_filename.
# Return TRUE if it succeeds so we can add the Analysis
# button to the Results window.
# Otherwise return FALSE, and don't draw it.
my $fcb = Gtk3::FileChooserButton->new( 'just a test', 'open', );
$fcb->set_filename( '/usr/bin/clamtk' );
if ( $fcb->get_filename ) {
return TRUE;
} else {
return FALSE;
}
}
1;