From 08b6b503a645031c2e92929197bb9cd50b772749 Mon Sep 17 00:00:00 2001 From: Daniel Lysfjord Date: Sat, 9 Mar 2024 15:36:42 +0100 Subject: [PATCH] All the files --- bin/parserfilter | 51 ++++++++++ bin/parserfilter-tester | 46 +++++++++ lib/addtolist.pm | 65 +++++++++++++ lib/apache.pm | 45 +++++++++ lib/apache_parser.pm | 81 ++++++++++++++++ lib/block.pm | 78 +++++++++++++++ lib/config.pm | 209 ++++++++++++++++++++++++++++++++++++++++ lib/db.pm | 48 +++++++++ lib/dovecot.pm | 43 +++++++++ lib/dovecot_parser.pm | 113 ++++++++++++++++++++++ lib/exim.pm | 45 +++++++++ lib/exim_parser.pm | 173 +++++++++++++++++++++++++++++++++ lib/file.pm | 72 ++++++++++++++ lib/geoip.pm | 40 ++++++++ lib/gitea.pm | 43 +++++++++ lib/gitea_parser.pm | 41 ++++++++ lib/localcheck.pm | 24 +++++ lib/logger.pm | 35 +++++++ lib/parser.pm | 142 +++++++++++++++++++++++++++ lib/ssh.pm | 59 ++++++++++++ lib/ssh_parser.pm | 178 ++++++++++++++++++++++++++++++++++ lib/stats.pm | 90 +++++++++++++++++ 22 files changed, 1721 insertions(+) create mode 100755 bin/parserfilter create mode 100755 bin/parserfilter-tester create mode 100644 lib/addtolist.pm create mode 100644 lib/apache.pm create mode 100644 lib/apache_parser.pm create mode 100644 lib/block.pm create mode 100644 lib/config.pm create mode 100644 lib/db.pm create mode 100644 lib/dovecot.pm create mode 100644 lib/dovecot_parser.pm create mode 100644 lib/exim.pm create mode 100644 lib/exim_parser.pm create mode 100644 lib/file.pm create mode 100644 lib/geoip.pm create mode 100644 lib/gitea.pm create mode 100644 lib/gitea_parser.pm create mode 100644 lib/localcheck.pm create mode 100644 lib/logger.pm create mode 100644 lib/parser.pm create mode 100644 lib/ssh.pm create mode 100644 lib/ssh_parser.pm create mode 100644 lib/stats.pm diff --git a/bin/parserfilter b/bin/parserfilter new file mode 100755 index 0000000..cf24f48 --- /dev/null +++ b/bin/parserfilter @@ -0,0 +1,51 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use diagnostics; +use sigtrap qw/handler signal_handler normal-signals/; +use Tie::Syslog; +use My::Savepid qw(savepid); +use My::parser::parser; +use Glib; + +my $config_file = '/usr/local/etc/parserfilter.conf'; +my $pidfile = '/var/run/parserfilter.pid'; +my $program = $0; + +unless(-f $config_file) { + die "Configuration file $config_file does not exist"; +} + +my $x; +my $loop = Glib::MainLoop->new; +my $parser = My::parser::parser->new($config_file); +my $time = Glib::Timeout->add(1000, \&loopsie); + +if(my $tp = fork) { + exit 0; +} else { + my $pid = $$; + $x = tie *STDERR, 'Tie::Syslog', 'local0.err',$program,'pid','unix'; + $x->ExtendedSTDERR(); + &savepid($pid,$pidfile); + close STDIN; + $parser->load_parsers; + $loop->run; +} + +sub loopsie { + my $result = $parser->parse_all; + return 1 if($result); + return 0; +} + +sub signal_handler { + my $signal = shift; + if($signal eq 'HUP') { + $parser->{'config'}->{'logger'}->reload_log; + $parser->{'config'}->{'logger'}->log('Log reloaded'); + } else { + $loop->quit; + $parser->{'config'}->{'logger'}->log("$program stopped"); + } +} diff --git a/bin/parserfilter-tester b/bin/parserfilter-tester new file mode 100755 index 0000000..c81a375 --- /dev/null +++ b/bin/parserfilter-tester @@ -0,0 +1,46 @@ +#!/usr/bin/env perl +use strict; +use warnings; +use Getopt::Long; +use My::parser::stats; +my $module; +my @modules = ('ssh','dovecot','exim','apache','gitea_ssh'); +my $program = $0; +GetOptions ("module=s" => \$module); #Only test one module +unless($module) { + print 'No module specified, use argument --module=(ssh|dovecot|exim|apache|gitea_ssh)'."\n"; + exit; +} +my @matches = grep { /$module/ } @modules; +unless(@matches) { + print 'Unsupported module '.$module.' specified, use argument --module=(ssh|dovecot|exim|apache|gitea_ssh)'."\n"; + exit; +} +print "Please paste a line to parse here:\n"; +my $frompipe = ; +chomp($frompipe); #One line is fine for us, user may fuck up, but we're good.. +my $parser = &load($module); +my $result = $parser->parser($frompipe); +if($result->{'retval'}) { + print 'Parser said: '.$result->{'retmsg'}."\n"; + print 'Regarded as a hostile action'."\n" if($result->{'hostile'}); + print 'Host: '.$result->{'host'}."\n"; +} else { + print 'Parser found no match'."\n"; +} + +sub load { + my $parser = shift; + my $filename = 'My/parser/'.$parser.'_parser.pm'; + my $newclass; + eval { + require $filename; + my $classname = 'My::parser::'.$parser.'_parser'; + $newclass = $classname->new || die "Failed to load parser for $parser"; + } or do { + my $e = $@; + print 'Could not load parser '.$module.' from file '.$filename.': '.$e."\n"; + exit; + }; + return $newclass; +} diff --git a/lib/addtolist.pm b/lib/addtolist.pm new file mode 100644 index 0000000..ad55bb2 --- /dev/null +++ b/lib/addtolist.pm @@ -0,0 +1,65 @@ +package My::parser::addtolist; +use strict; +use warnings; +use DBI; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + $self->{'config'} = $config; + $self->{'block_table'} = 'badhosts'; + return $self; +} + +sub add { + my $self = shift; + my $params = shift; + die "No params" unless $params; + $self->{'dbh'} = $self->{'config'}->get_dbh unless($self->{'dbh'}); + my $return = {}; + $return = $self->addtoblacklist($params) if($params->{'list'} eq 'black'); + $return = $self->addtorejectlist($params) if($params->{'list'} eq 'reject'); + return $return; +} + +sub addtoblacklist { + my $self = shift; + my $work = shift; + my $host = $work->{'host'}; + my $asn = $work->{'asn'} || 0; + my $iso = $work->{'iso'} || ''; + return { retval => 0, retmsg => "No host specified" } unless($host); + my $sth = $self->{'dbh'}->prepare(" + SELECT COUNT(*) + FROM reject_iptables + WHERE ip = ? AND + time > DATE_SUB(NOW(), INTERVAL 5 MINUTE)"); + $sth->execute($host); + my $rows = $sth->fetchrow_arrayref->[0]; + unless($rows) { + my $sth = $self->{'dbh'}->prepare("INSERT INTO reject_iptables(ip,asn,iso) VALUES(?,?,?)") || return { retval => 0, retmsg => 'Failed to prepare statement in addtoblacklist '.DBI::errstr }; + $sth->execute($host,$asn,$iso) || return { retval => 0, retmsg => 'Failed to execute statement in addtoblacklist '.DBI::errstr }; + system "/sbin/pfctl -q -t $self->{'block_table'} -T add $host"; + system "/sbin/pfctl -q -k $host"; + return { retval => 1, retmsg => "Added pf rule" }; + } else { + return { retval => 1, retmsg => "Recently got $rows entries" }; + } +} + +sub addtorejectlist { + my $self = shift; + my $work = shift; + my $host = $work->{'host'}; + my $service = $work->{'service'} || ''; + my $asn = $work->{'asn'} || 0; + my $iso = $work->{'iso'} || ''; + return { retval => 0, retmsg => "No host specified" } unless($host); + my $sth = $self->{'dbh'}->prepare("INSERT INTO reject(ip,service,asn,iso) VALUES(?,?,?,?)") || return { retval => 0, retmsg => 'Failed to prepare statement in addtorejectlist '.DBI::errstr }; + $sth->execute($host,$service,$asn,$iso) || return { retval => 0, retmsg => 'Failed to executute statement in addtorejectlist '.DBI::errstr }; + return { retval => 1, retmsg => "Added to reject list" }; +} + +1; diff --git a/lib/apache.pm b/lib/apache.pm new file mode 100644 index 0000000..a563b12 --- /dev/null +++ b/lib/apache.pm @@ -0,0 +1,45 @@ +package My::parser::apache; +use strict; +use warnings; +use File::Tail 0.91; +use My::parser::apache_parser; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + + $self->{'config'} = $config; + $self->{'parser'} = My::parser::apache_parser->new(); + return $self; +} + +sub parse { + my $self = shift; + my @result; + while(my $string = $self->fetch) { + last unless($string); + if (my $line = $self->{'parser'}->parser($string)) { + push(@result,$line); + } + } + return { retval => 0 } unless(scalar(@result)); # nothing to say, nothing to report + return { retval => 1, retmsg => 'Here comes the results', lines => \@result }; +} + +sub fetch { + my $self = shift; + my $fetcher = $self->{'config'}->get_fetcher('apache'); + die "Fetcher for apache went away?" unless($fetcher); + my $line; + my ($nfound,$timeleft,@pending) = File::Tail::select(undef,undef,undef,1,$fetcher); + foreach (@pending) { + $line = $_->read; + chomp($line); + } + return 0 unless($line); + return $line; +} + +1; diff --git a/lib/apache_parser.pm b/lib/apache_parser.pm new file mode 100644 index 0000000..73cbd48 --- /dev/null +++ b/lib/apache_parser.pm @@ -0,0 +1,81 @@ +package My::parser::apache_parser; +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + return $self; +} + +sub parser { + my $self = shift; + my $string = shift; + my ($reply,$hostile,$host) = ("No match for $string",0,''); + my $re_host = qr/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/; + if($string =~ m/proxy_fcgi:error.*Got error 'Primary script unknown'/) { + $_ = $string; + $reply = 'Request for unknown fcgi-script'; + $hostile = 1; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/script not found or unable to stat/) { + $_ = $string; + $reply = 'Script not found'; + $hostile = 1; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ap_pass_brigade failed in handle_request_ipc function/) { + $_ = $string; + $hostile = 1; + $reply = 'Connection closed early'; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/Invalid URI in request/) { + $_ = $string; + $hostile = 1; + $reply = 'Invalid URI'; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/invalid URI path /) { + $_ = $string; + $hostile = 1; + $reply = 'Invalid URI'; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/\(63\)File name too long: /) { + $_ = $string; + $hostile = 1; + $reply = 'File name too long'; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ AH00135: Invalid method in request /) { + $_ = $string; + $hostile = 1; + $reply = 'Invalid request method'; + PARSE: + m/(\[client\ ($re_host)\:0\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/mod_fcgid: cleanup zombie process/) { + $reply = 'fcgi process killed'; + } elsif($string =~ m/scoreboard already in used/) { + $reply = 'Scoreboard already in use'; + } + return { retval => 1, retmsg => $reply, hostile => $hostile, host => $host, string => $string }; +} + +return 1; diff --git a/lib/block.pm b/lib/block.pm new file mode 100644 index 0000000..a67f69e --- /dev/null +++ b/lib/block.pm @@ -0,0 +1,78 @@ +package My::parser::block; +use strict; +use warnings; +use My::parser::addtolist; +use My::parser::stats; +use POSIX qw(ceil floor); + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + $self->{'config'} = shift; + $self->{'addtolist'} = My::parser::addtolist->new($self->{'config'}); + $self->{'stats'} = My::parser::stats->new($self->{'config'}); + return $self; +} + +sub blocklogic { + my $self = shift; + my $result = shift; + return { retval => 0, retmsg => 'Too few arguments to blocklogic' } unless($result); + my $host = $result->{'host'}; + my $service = $result->{'service'}; + my $geoip = $result->{'geoip'}; + my $short = $self->{'config'}->get_as_single_val('config','short'); + my $long = $self->{'config'}->get_as_single_val('config','long'); + my $retstr; + my $stats; + my $logline; + my $modifier = 0; + + my $fromreject = $self->{'addtolist'}->add( { list => 'reject', host => $host, service => $service, asn => $geoip->{'asn'}, iso => $geoip->{'iso'} } ); + return { retval => 0, hostile => 0, retmsg => $fromreject->{'retmsg'} } unless($fromreject->{'retval'}); + if ($stats = $self->{'stats'}->checker({ host => $host, asn => $geoip->{'asn'}, iso => $geoip->{'iso'} })) { + $self->{'config'}->{'logger'}->log($stats->{'retmsg'}) unless($stats->{'retval'}); + my $hrs = $stats->{$short}->{'reject'} || 0; + my $ars = $stats->{$short}->{'asn'} || 0; + my $irs = $stats->{$short}->{'iso'} || 0; + my $hbs = $stats->{$short}->{'blocks'} || 0; + my $abs = $stats->{$short}->{'block_asn'} || 0; + my $ibs = $stats->{$short}->{'block_iso'} || 0; + my $hrl = $stats->{$long}->{'reject'} || 0; + my $arl = $stats->{$long}->{'asn'} || 0; + my $irl = $stats->{$long}->{'iso'} || 0; + my $hbl = $stats->{$long}->{'blocks'} || 0; + my $abl = $stats->{$long}->{'block_asn'} || 0; + my $ibl = $stats->{$long}->{'block_iso'} || 0; + my $hostile = 0; + my $shortrejectpoints = ($hrs * 100) + ($ars * 50) + ($irs * 25); + my $shortblockpoints = ($hbs * 750) + ($abs * 250) + ($ibs * 50); + my $longrejectpoints = ($hrl * 30) + ($arl * 10) + ($irl); + my $longblockpoints = ($hbl * 150) + ($abl * 75) + ($ibl * 10); + my $points = $shortrejectpoints + $shortblockpoints + $longrejectpoints + $longblockpoints; + if (my $recent_hostile = $self->{'stats'}->recent_hostile()) { + unless($recent_hostile->{'retval'}) { + $self->{'config'}->{'logger'}->log($recent_hostile->{'retmsg'}) + } else { + my $rh = $recent_hostile->{'rows'} - 1; + if($rh > 0) { + $modifier = 1 + ($rh / 5); + $points = floor($points * $modifier); + } + } + } + $hostile++ if($points > 999); + + my $fromblack = $self->{'addtolist'}->add( { list => 'black', host => $host, asn => $geoip->{'asn'}, iso => $geoip->{'iso'} } ) if($hostile); + $self->{'config'}->{'logger'}->log($fromblack->{'retmsg'}) unless($fromblack->{'retval'}); + $logline .= $fromblack->{'retmsg'}.', ' if($fromblack->{'retmsg'}); + $logline .= "Points: $points"; + $logline .= '(mod='.$modifier.')' if($modifier); + return { retval => 1, hostile => $hostile, retmsg => $logline }; + } else { + return { retval => 0, hostile => 0, retmsg => 'Blocklogic failed, no idea why:)' }; + } +} + +1; diff --git a/lib/config.pm b/lib/config.pm new file mode 100644 index 0000000..9f35fe5 --- /dev/null +++ b/lib/config.pm @@ -0,0 +1,209 @@ +package My::parser::config; +use strict; +use warnings; +use Getopt::Long; + +sub new { + my $class = shift; + my $file = shift; + + my $self = {}; + bless ($self, $class); + + my $test = 0; + my $parse; + + GetOptions( + "cf=s" => \$file, #string, config file from options + "test=i" => \$test, #integer, test mode (no writing to db, no blacklisting) + "parse=s" => \$parse, #string, parser testing. Parse only the submitted line, test=1 needed + ); + $self->{'file'} = $file; + $self->parse if($self->load); + $self->{'config'}->{'test'} = $test; + $self->{'config'}->{'parse'} = $parse; + return $self; +} + +sub load { + my $self = shift; + my $file = $self->{'file'}; + + open(FH, "<", "$file") || die "Could not open $file for reading: ".$!; + while() { + my $line = $_; + chomp($line); + next unless($line =~ m/\S/); + push(@{$self->{'conf_file'}},$line) unless($line =~ m/^\#/); + } + close(FH); + return 1; +} + +sub parse { + my $self = shift; + foreach(@{$self->{'conf_file'}}) { + my ($opt,$arg) = split("="); + $opt =~ s/^\s+//; + $opt =~ s/\s+$//; + $arg =~ s/^\s+//; + $arg =~ s/\s+$//; + my @argarr = split("','",$arg); + foreach my $argie(@argarr) { + $argie =~ s/^\'//; + $argie =~ s/\'$//; + push(@{$self->{'config'}->{$opt}},$argie); + } + } + delete $self->{'conf_file'}; + return 1; +} + +sub get_modules { + my $self = shift; + my $type = $self->{'config'}->{'modules'}; + return $type; +} + +sub get_parsers { + my $self = shift; + my $return = $self->{'parsers'}; + return $return; +} + +sub get_fetchers { + my $self = shift; + my $return = $self->{'fetchers'}; + return $return; +} + +sub get_parser_info { + my $self = shift; + my $m = shift; + my $toret = {}; + if(defined($self->{'config'}->{$m})) { + for(my $i = 0; $i < @{$self->{'config'}->{$m}}; $i++) { + my $val; + $val = 'type' if($i == 0); + $val = 'source' if($i == 1); + $val = 'program' if($i == 2); + $toret->{$val} = @{$self->{'config'}->{$m}}[$i] + } + } else { + return; + } + return $toret; +} + +sub get_fetcher { + my $self = shift; + my $m = shift; + my $toret; + if(defined($self->get_parser_info($m))) { + my $type = $self->get_parser_info($m)->{'type'}; + if ($toret = $self->{'config'}->{'fetchers'}->{$type}->{$m}) { + return $toret; + } + } + return; +} + +sub get_fetcher_module { + my $self = shift; + my $m = shift; + my $toret; + if(defined($self->{'fetcher'}->{$m})) { + my $toret = $self->{'fetcher'}->{$m}; + return $toret; + } else { + return; + } +} + +sub get_dbh { + my $self = shift; + my $dbh = $self->{'config'}->{'fetchers'}->{'db'}->{'dbh'}; + return $dbh; +} + +sub set_dbh { + my $self = shift; + my $dbh = shift; + $self->{'config'}->{'fetchers'}->{'db'}->{'dbh'} = $dbh; + return 1; +} + +sub set_fetcher { + my $self = shift; + my $type = shift; ## Type of source + my $m = shift; ## m for module? name of service + my $fetcher = shift; ## hash for reaching fetcher + if($type && $m) { + $self->{'config'}->{'fetchers'}->{$type}->{$m} = $fetcher; + #print localtime(time).": Fetcher set to $type($fetcher) for service $m\n"; FIXME add debug option in config? + return 1; + } + return 0; +} + +sub set { + my $self = shift; + my $node = shift || undef; + my $key = shift || undef; + my $value = shift || undef; + if(defined($node) && defined($key) && defined($value)) { + $self->{$node}->{$key} = $value; + return 1; + } elsif (defined($node) && defined($key)) { + $self->{$node} = $key; + return 1; + } + return 0; +} + +sub get { + my $self = shift; + my $node = shift || undef; + my $key = shift || undef; + if(defined($node) && defined($key)) { + my $toret = $self->{$node}->{$key}; + return $toret; + } elsif(defined($node)) { + my $toret = $self->{$node}; + return $toret; + } + return 0; +} + +sub get_as_single_val { + my $self = shift; + my $node = shift || undef; + my $key = shift || undef; + my $toret; + if(defined($node) && defined($key)) { + $toret = exists $self->{$node}->{$key} ? $self->{$node}->{$key} : undef; + } elsif(defined($node)) { + $toret = exists $self->{$key} ? $self->{$key} : undef; + } + if($toret) { + if(scalar(@{$toret})) { + $toret = join('',@{$toret}); + } + return $toret; + } + return; +} + +sub print { + my $self = shift; + foreach my $key(keys %{$self->{'config'}}) { + $self->{'config'}->{'logger'}->log("$key: "); + my $tolog; + foreach my $arg(@{$self->{'config'}->{$key}}) { + $tolog .= "$arg "; + } + $self->{'config'}->{'logger'}->log($tolog); + } + return 1; +} +1; diff --git a/lib/db.pm b/lib/db.pm new file mode 100644 index 0000000..d5ea127 --- /dev/null +++ b/lib/db.pm @@ -0,0 +1,48 @@ +package My::parser::db; +use strict; +use warnings; +use DBI; +#use Scalar::Util qw(weaken); + +sub new { + my $class = shift; + my $config = shift; + my $rest = shift; + return \$class if($rest); + my $self = {}; + bless ($self, $class); + + $self->{'config'} = $config; + return $self; +} + +sub init { + my $self = shift; + my $parser = shift; ## not needed for this module as the db-connection is for all modules + $self->connect || die "Could not connect to db"; + return 1; +} + +sub connect { + my $self = shift; + my $config = $self->{'config'}; + my $usr = $config->get_as_single_val('config','dbusr'); + my $pwd = $config->get_as_single_val('config','dbpwd'); + my $host = $config->get_as_single_val('config','dbhost'); + my $db = $config->get_as_single_val('config','db'); + my $dbh; + if($usr && $pwd && $host && $db) { + while(1) { + last if($dbh = DBI->connect("DBI:mysql:database=$db;host=$host",$usr,$pwd, { PrintError => 1, mysql_auto_reconnect=>1, AutoCommit => 1 })); + sleep(10); + } + $config->set_dbh($dbh); + #$self->{'config'}->{'logger'}->log("Sucsessfully connected to db"); FIXME add debug to config? + return 1; + } else { + $self->{'config'}->{'logger'}->log("Unable to connect to db, not enough parameters"); + return 0; + } +} + +1; diff --git a/lib/dovecot.pm b/lib/dovecot.pm new file mode 100644 index 0000000..35215c6 --- /dev/null +++ b/lib/dovecot.pm @@ -0,0 +1,43 @@ +package My::parser::dovecot; +use strict; +use warnings; +use My::parser::dovecot_parser; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + + $self->{'config'} = $config; + $self->{'parser'} = My::parser::dovecot_parser->new(); + return $self; +} + +sub parse { + my $self = shift; + my @result; + while(my $string = $self->fetch) { + last unless($string); + if (my $line = $self->{'parser'}->parser($string)) { + push(@result,$line); + } + } + return { retval => 0 } unless(scalar(@result)); + return { retval => 1, retmsg => 'Here comes the results', lines => \@result }; +} + +sub fetch { + my $self = shift; + my $fetcher = $self->{'config'}->get_fetcher('dovecot'); + my $line; + my ($nfound,$timeleft,@pending) = File::Tail::select(undef,undef,undef,1,$fetcher); + foreach (@pending) { + $line = $_->read; + chomp($line); + } + return 0 unless($line); + return $line; +} + +1; diff --git a/lib/dovecot_parser.pm b/lib/dovecot_parser.pm new file mode 100644 index 0000000..1475bc0 --- /dev/null +++ b/lib/dovecot_parser.pm @@ -0,0 +1,113 @@ +package My::parser::dovecot_parser; +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + return $self; +} + +sub parser { + my $self = shift; + my $string = shift; + my ($reply,$hostile,$host) = ("No match for $string",0,''); + my $re_host = qr/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/; + my $re_usr = qr/[a-zA-Z0-9_-]*/; + my $re_pid = qr/[0-9]*/; + my $re_uid = qr/[a-zA-Z0-9_\-+:\/]*/; + my $re_info = qr/\($re_usr\)\<$re_pid\>\<$re_uid\>/; + if($string =~ m/ Info: Disconnected: Logged out /) { + $reply = 'Normal logout'; + } elsif($string =~ m/ imap-login: (Info: |)Login: /) { + $reply = 'Normal login'; + } elsif($string =~ m/ indexer-worker$re_info: Info: /) { + $reply = 'Indexer worker message'; + } elsif($string =~ m/ Disconnected (in IDLE|for inactivity) /) { + $reply = 'Idle disconnect'; + } elsif($string =~ m/imap($re_info): (Info: |Disconnected: |)Connection closed/) { + $reply = 'Normal connection closed'; + } elsif($string =~ m/ imap$re_info: (Info: |)Disconnected: Logged out /) { + $reply = 'Normal log out'; + } elsif($string =~ m/ master: Warning: Time moved /) { + $reply = 'Clock adjustment'; + } elsif($string =~ m/auth failed/) { + $_ = $string; + $reply = 'Auth failure'; + $hostile = 1; + PARSER: + m/ (rip=($re_host)) /gcix && do { + $host = $2; + }; + } elsif($string =~ m/(Authentication error (\(Password mismatch\?\)|unknown user))/) { + $_ = $string; + $reply = 'Unknown user or auth error'; + $hostile = 1; + PARSER: + m/(([a-zA-Z0-9@._-]*),($re_host),.*\): pam_authenticate\(\) failed: (Authentication error \(Password mismatch\?\)|unknown user))/gi && do { + $host = $3; + }; + } elsif($string =~ m/unknown user/) { + $_ = $string; + $reply = 'Unknown user'; + $hostile = 1; + PARSER: + m/(((Info: |)conn unix:auth-worker \(uid=([0-9]{1,9})\): auth-worker<([0-9]{1,9}>: pam\(([a-zA-Z0-9@._-]*),($re_host),<($re_uid)>\): unknown user)))/gi && do { + $host = $7; + }; + } elsif($string =~ m/ imap-login: (Info: |)Disconnected/) { + if($string =~ m/Connection closed/) { + $hostile = 0; + $reply = 'Disconnecting is legit'; + } elsif($string =~ m/ TLS handshaking: /) { + $reply = 'TLS error'; + $hostile = 1; + } elsif($string =~ m/Too many invalid commands/) { + $reply = 'Too many invalid commands'; + $hostile = 1; + } elsif($string =~ m/TLS: Disconnected,/) { + $reply = 'Likely a sleeping android'; + $hostile = 0; + } elsif($string =~ m/client didn't finish SASL auth/) { + $reply = 'Timeout waiting for SASL auth'; + $hostile = 1; + } elsif($string =~ m/no auth attempts in/) { + if($string =~ m/, secured/) { + $reply = 'Secured Disconnect during auth, either sleeping phone or attack on webmail'; + $hostile = 0; + } else { + $reply = 'Non-secure disconnect during auth'; + $hostile = 1; + } + } + if($hostile) { + $_ = $string; + PARSER: + m/\ (rip=($re_host))/gcix && do { + $host = $2; + }; + } + } elsif($string =~ m/ imap-login: (Info: |) Aborted login /) { + $_ = $string; + $reply = 'Aborted login'; + $hostile = 1; + PARSER: + m/ (rip=($re_host)) /gcix && do { + $host = $2; + }; + } elsif($string =~ m/unknown user$/) { + $_ = $string; + $hostile = 1; + $reply = 'Unknown user'; + PARSER: + m/Info: pam\([a-zA-Z0-9@._-]*,($re_host),\<.*\>\): unknown user/gi && do { + $host = $1; + }; + } else { + $reply = 'No match for '.$string; + } + return { retval => 1, retmsg => $reply, hostile => $hostile, host => $host, string => $string }; +} + +1; diff --git a/lib/exim.pm b/lib/exim.pm new file mode 100644 index 0000000..d2e7eaf --- /dev/null +++ b/lib/exim.pm @@ -0,0 +1,45 @@ +package My::parser::exim; +use strict; +use warnings; +use File::Tail 0.91; +use My::parser::exim_parser; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + + $self->{'config'} = $config; + $self->{'parser'} = My::parser::exim_parser->new(); + return $self; +} + +sub parse { + my $self = shift; + my @result; + while(my $string = $self->fetch) { + last unless($string); + if (my $line = $self->{'parser'}->parser($string)) { + push(@result,$line); + } + } + return { retval => 0 } unless(scalar(@result)); # nothing to say, nothing to report + return { retval => 1, retmsg => 'Here comes the results', lines => \@result }; +} + +sub fetch { + my $self = shift; + my $fetcher = $self->{'config'}->get_fetcher('exim'); + die "Fetcher for exim went away?" unless($fetcher); + my $line; + my ($nfound,$timeleft,@pending) = File::Tail::select(undef,undef,undef,1,$fetcher); + foreach (@pending) { + $line = $_->read; + chomp($line); + } + return 0 unless($line); + return $line; +} + +1; diff --git a/lib/exim_parser.pm b/lib/exim_parser.pm new file mode 100644 index 0000000..bbed246 --- /dev/null +++ b/lib/exim_parser.pm @@ -0,0 +1,173 @@ +package My::parser::exim_parser; +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + return $self; +} + +sub parser { + my $self = shift; + my $string = shift; + my ($reply,$hostile,$host) = ("No match for $string",0,''); + my $re_host = qr/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/; + my $re_msgid = qr/[0-9a-z]{6}-[0-9a-z]{11}-[0-9a-z]{4}/i; + my $saslauth = qr/saslauthd_server authenticator failed/i; + if($string =~ m/ ($re_msgid) /) { + $reply = 'MSG ID, normal delivery'; + } elsif($string =~ m/(Start|End) queue run/) { + $reply = 'Queue run'; + } elsif($string =~ m/1 accept\(\) failure/) { + $reply = 'Connection aborted by software'; + } elsif($string =~ m/SSL verify error: /) { + if($string =~ m/self signed certificate in certificate chain/) { + $reply = 'Self signed certificate in chain'; + } elsif($string =~ m/error=self signed certificate cert=/) { + $reply = 'Self signed certificate'; + } else { + $reply = 'Other SSL verify errors'; + } + } elsif($string =~ m/Temporary DNS error while checking SPF record/) { + $reply = 'Temporary DNS error while checking SPF record'; + } elsif($string =~ m/$saslauth /) { + $_ = $string; + $hostile = 1; + $reply = 'saslauth error'; + PARSER: + m/(\ \[($re_host)\]:)/gcix && do { + $host = $2; + }; + } elsif($string =~ m/> rejected (after DATA|RCPT)/) { + $_ = $string; + $reply = 'Rejected'; + $hostile = 1; + PARSER: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/\ rejected (AUTH|MAIL)\ /) { + $_ = $string; + $reply = 'Rejected MAIL or AUTH'; + $hostile = 1; + PARSER: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ too many (nonmail|syntax or protocol errors|unrecognized commands) /) { + $_ = $string; + $reply = 'protocol violations'; + $hostile = 1; + PARSER: + m/(\ \[($re_host)\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ SMTP protocol synchronization error /) { + $_ = $string; + $reply = 'protocol errors'; + $hostile = 1; + PARSE: + m/(\[($re_host)\]\ ) /gcix && do { + $host = $2; + }; + } elsif($string =~ m/ TLS error on connection /) { + $_ = $string; + $reply = 'TLS error'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ TLS error \(SSL_read\)\: on connection /) { + $_ = $string; + $reply = 'TLS error'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ no IP address found for host /) { + $_ = $string; + $reply = 'Problems resolving hostname'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ no host name found for IP address /) { + $_ = $string; + $reply = 'No hostname'; + $hostile = 0; + } elsif($string =~ m/unexpected disconnection while reading SMTP command from/) { + $_ = $string; + $reply = 'SMTP error, disconnected'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ sender verify fail for /) { + $_ = $string; + $reply = 'Sender verify failure'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ rejected (EH|HE)LO from /) { + $_ = $string; + $reply = 'HELO/EHLO error'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\](:|)\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ You are not me /) { + $_ = $string; + $reply = 'Forged HELO'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ rejected after DATA /) { + $_ = $string; + $reply = 'Parser error'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\]\ )/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ (login_saslauthd_server|LOGIN|PLAIN) authenticator failed for /) { + $_ = $string; + $reply = 'Auth error'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/ SMTP command timeout /) { + $_ = $string; + $reply = 'SMTP timeout'; + $hostile = 1; + PARSE: + m/(\ \[($re_host)\])/gcix && do { + $host = $2; + }; + } elsif($string =~ m/SSL_write: /) { + unless($string =~ m/syscall: Broken pipe/) { #If we get a broken pipe, it's most likely because we blocked the ip earlier, and this is just the pipe timing out + $_ = $string; + $reply = 'SSL error'; + $hostile = 1; + PARSE: + m/(\ \[$re_host\]\))/gcix && do { + $host = $2; + }; + } + } + return { retval => 1, retmsg => $reply, hostile => $hostile, host => $host, string => $string }; +} + +return 1; diff --git a/lib/file.pm b/lib/file.pm new file mode 100644 index 0000000..674cd35 --- /dev/null +++ b/lib/file.pm @@ -0,0 +1,72 @@ +package My::parser::file; +use strict; +use warnings; +use File::Tail 0.91; + + +sub new { + my $class = shift; + my $config = shift; + my $rest = shift; + return \$class if($rest); + my $self = {}; + bless ($self, $class); + $self->{'config'} = $config; + $self->{'config'}->{'fetcher'}->{'file'} = $self; + return $self; +} + +sub init { + my $self = shift; + my $parser = shift; + if($parser) { + my $hash = $self->{'config'}->get_parser_info($parser); + if($hash->{'type'} eq 'file') { + my %opts = %{ &set_defaults }; + my $filename = $hash->{'source'}; + my $maxinterval = $self->{'config'}->get_as_single_val('config','maxinterval'); + my $interval = $self->{'config'}->get_as_single_val('config','interval'); + my $adjustafter = $self->{'config'}->get_as_single_val('config','adjustafter'); + my $resetafter = $self->{'config'}->get_as_single_val('config','resetafter'); + my $maxbuf = $self->{'config'}->get_as_single_val('config','maxbuf'); + my $nowait = $self->{'config'}->get_as_single_val('config','nowait'); + my $ignore_nonexistant = $self->{'config'}->get_as_single_val('config','ignore_nonexistant'); + my $tail = $self->{'config'}->get_as_single_val('config','tail'); + my $reset_tail = $self->{'config'}->get_as_single_val('config','reset_tail'); + $opts{'name'} = $filename; + $opts{'maxinterval'} = $maxinterval if(defined($maxinterval)); + $opts{'interval'} = $interval if(defined($interval)); + $opts{'adjustafter'} = $adjustafter if(defined($adjustafter)); + $opts{'resetafter'} = $resetafter if(defined($resetafter)); + $opts{'maxbuf'} = $maxbuf if(defined($maxbuf)); + $opts{'nowait'} = $nowait if(defined($nowait)); + $opts{'ignore_nonexistant'} = $ignore_nonexistant if(defined($ignore_nonexistant)); + $opts{'tail'} = $tail if(defined($tail)); + $opts{'reset_tail'} = $reset_tail if(defined($reset_tail)); + $self->{'config'}->{'logger'}->log("Initializing fetcher file from parser $parser with file $filename"); + my $newfetcher = File::Tail->new(%opts); + $self->{'config'}->set_fetcher('file',$parser,$newfetcher); + return 1; + } else { + return 0; + } + } else { + return 0; + } +} + +sub set_defaults { + my $opts; + $opts->{'maxinterval'} = 60; #max time spent sleeping between checks + $opts->{'interval'} = 10; #initial time before first check + $opts->{'adjustafter'} = 10; #number of times $interval passes before adjusting the check interval + $opts->{'resetafter'} = $opts->{'interval'} * $opts->{'adjustafter'}; #Number of seconds after last change to file before reopening the file + $opts->{'maxbuf'} = 16384; + $opts->{'nowait'} = 0; #Does not block on read, but returns an empty string if there is nothing to read. + $opts->{'ignore_nonexistant'} = 0; #Do not complain if the file doesn't exist when it is first opened or when it is to be reopened. + $opts->{'tail'} = 0; #When first started, read and return C lines from the file. If C is zero, start at the end of file. If C is negative, return the whole file. + $opts->{'reset_tail'} = 0; #Same as tail, but for when $resetafter has gone by without any changes to the file + return $opts; +} + +1; diff --git a/lib/geoip.pm b/lib/geoip.pm new file mode 100644 index 0000000..5730281 --- /dev/null +++ b/lib/geoip.pm @@ -0,0 +1,40 @@ +package My::parser::geoip; +use strict; +use warnings; +use IO::Socket::INET; + +sub new { + my $class = shift; + my $self = {}; + bless ($self,$class); + $self->{'config'} = shift; + my $config = $self->{'config'}; + $config->{'logger'}->log('Loaded My::parser::geoip'); + return $self; +} + +sub parse { + my $self = shift; + my $host = shift; + return unless($host =~ m/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/); + $| = 1; + my $socket = new IO::Socket::INET ( + PeerHost => '10.0.0.100', + PeerPort => '7777', + Proto => 'tcp' + ); + $self->{'config'}->{'logger'}->log("Failed to look up GeoIP for $host, could not connect to GeoIP resolver") unless $socket; + return { asn => 0, iso => '' } unless $socket; + my $size = $socket->send($host."\n"); + my $response; + $socket->recv($response, 1024); + shutdown($socket,1); + $socket->close; + my ($asn,$iso) = split (",", $response); + my $r = {}; + $r->{'asn'} = $asn; + $r->{'iso'} = $iso; + return $r; +} + +1; diff --git a/lib/gitea.pm b/lib/gitea.pm new file mode 100644 index 0000000..6501836 --- /dev/null +++ b/lib/gitea.pm @@ -0,0 +1,43 @@ +package My::parser::gitea; +use strict; +use warnings; +use My::parser::gitea_parser; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + + $self->{'config'} = $config; + $self->{'parser'} = My::parser::gitea_parser->new(); + return $self; +} + +sub parse { + my $self = shift; + my @result; + while(my $string = $self->fetch) { + last unless($string); + if (my $line = $self->{'parser'}->parser($string)) { + push(@result,$line); + } + } + return { retval => 0 } unless(scalar(@result)); + return { retval => 1, retmsg => 'Here comes the results', lines => \@result }; +} + +sub fetch { + my $self = shift; + my $fetcher = $self->{'config'}->get_fetcher('gitea'); + my $line; + my ($nfound,$timeleft,@pending) = File::Tail::select(undef,undef,undef,1,$fetcher); + foreach (@pending) { + $line = $_->read; + chomp($line); + } + return 0 unless($line); + return $line; +} + +1; diff --git a/lib/gitea_parser.pm b/lib/gitea_parser.pm new file mode 100644 index 0000000..1dc75a0 --- /dev/null +++ b/lib/gitea_parser.pm @@ -0,0 +1,41 @@ +package My::parser::gitea_parser; +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + return $self; +} + +sub parser { + my $self = shift; + my $string = shift; + my ($reply,$hostile,$host) = ("No match for $string",0,''); + my $re_host = qr/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/; + if($string =~ m/sshConnectionFailed/) { + if($string =~ m/Failed connection from /) { + $_ = $string; + $reply = 'Failed connection'; + $hostile = 1; + PARSE: + m/($re_host)\:[0-9]{1,6} /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Failed authentication attempt from /) { + $_ = $string; + $reply = 'Failed auth'; + $hostile = 1; + PARSE: + m/($re_host)\:[0-9]{1,6}/gcix && do { + $host = $1; + }; + } + } else { + $reply = 'non-ssh lines not supported yet'; + } + return { retval => 1, retmsg => $reply, hostile => $hostile, host => $host, string => $string }; +} + +return 1; diff --git a/lib/localcheck.pm b/lib/localcheck.pm new file mode 100644 index 0000000..a06591f --- /dev/null +++ b/lib/localcheck.pm @@ -0,0 +1,24 @@ +package My::parser::localcheck; +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + return $self; +} + +sub islocal { + my $self = shift; + my $host = shift; + my @local_nets = ('127\.','10\.','192\.168\.','172\.((1[6-9])|(2[0-9])|(3[0-1]))\.','213\.236\.200\.(7|10)'); # array of regexes matching networks considered local and to be ignored + my $local = 0; + foreach my $test(@local_nets) { + $local = 1 if($host =~ m/^$test/); + last if $local; + } + return $local; +} + +return 1; diff --git a/lib/logger.pm b/lib/logger.pm new file mode 100644 index 0000000..52665e9 --- /dev/null +++ b/lib/logger.pm @@ -0,0 +1,35 @@ +package My::parser::logger; +use strict; +use warnings; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + $self->{'config'} = $config; + return $self; +} + +sub log { + my $self = shift; + my $str = shift; + my $fh = $self->{'logfh'}; + return 0 unless($str); + print $fh localtime(time).': '.$str."\n"; + return 1; +} + +sub reload_log { + my $self = shift; + my $file = $self->{'config'}->get_as_single_val('config','logfile'); + die 'No output logfile' unless($file); + close($self->{'logfh'}) if($self->{'logfh'}); + my $fh = $self->{'logfh'}; + open($fh, '>>', "$file") || die 'Could not open logfile for appending: '.$!; + select $fh; $| = 1; + $self->{'logfh'} = $fh; + return 1; +} + +1; diff --git a/lib/parser.pm b/lib/parser.pm new file mode 100644 index 0000000..eeb5724 --- /dev/null +++ b/lib/parser.pm @@ -0,0 +1,142 @@ +package My::parser::parser; +use strict; +use warnings; +use My::parser::logger; +use My::parser::config; +use My::parser::geoip; +use My::parser::localcheck; +use My::parser::block; + +sub new { + my $class = shift; + my $config_file = shift; + my $self = {}; + bless ($self, $class); + $self->{'config'} = My::parser::config->new($config_file) || die "Could not load config file $config_file"; + $self->{'config'}->{'logger'} = My::parser::logger->new($self->{'config'}); + $self->{'config'}->{'logger'}->reload_log; + $self->{'geoip'} = My::parser::geoip->new($self->{'config'}); + $self->{'localcheck'} = My::parser::localcheck->new; + $self->{'block'} = My::parser::block->new($self->{'config'}); + return $self; +} + +sub parse_all { + my $self = shift; + my $parsers = $self->{'config'}->get_parsers; + + foreach my $service (keys %{$parsers}) { + my $fromparser = $parsers->{$service}->parse; + if($fromparser->{'retval'} == 1) { + foreach my $line (@{$fromparser->{'lines'}}) { + my $msg = $line->{'retmsg'}; + my $host = $line->{'host'}; + my $hostile = $line->{'hostile'}; + if ($msg && $hostile && $host) { + unless($self->{'localcheck'}->islocal($host)) { + my $tolog = $host."($service"; + my $geoip = $self->{'geoip'}->parse($host); + if ($geoip->{'asn'} && $geoip->{'iso'}) { + $tolog .= ",$geoip->{'asn'},$geoip->{'iso'}): "; + } else { + $geoip->{'asn'} = 0; + $geoip->{'iso'} = ''; + $tolog .= '): '; + } + if(my $fromblock = $self->{'block'}->blocklogic({host => $host, geoip => $geoip, service => $service})) { + my $blmsg = $fromblock->{'retmsg'}; + $tolog .= $blmsg; + } + $tolog .= ", $msg"; + $self->{'config'}->{'logger'}->log($tolog); + } else { + $self->{'config'}->{'logger'}->log("$host is local"); + } + } elsif ($msg =~ m/^No match/) { + $self->{'config'}->{'logger'}->log("$service said: $msg"); + } elsif ($hostile) { + $self->{'config'}->{'logger'}->log("Parser error, $service reported hostile activity, but no host given. Message from parser was: $msg. String passed to parser: ".$line->{'string'}); + } + } + } + } + return 1; +} + + +sub load_parsers { + my $self = shift; + my $config = $self->{'config'}; + die 'No modules defined in config' unless(scalar(@{$config->get_modules})); + foreach my $parser(@{$config->get_modules}) { + if(my $newparser = $self->load($parser)) { + $config->set('parsers',$parser,$newparser); + my $module_info = $config->get_parser_info($parser); + my $fetcher_needed = $module_info->{'type'}; + my $isloaded = $config->{'loadedparsers'}->{$fetcher_needed}; + unless(defined($isloaded)) { + if(my $fetcher_loaded = $self->load($fetcher_needed)) { + $self->dyninit($fetcher_loaded,$fetcher_needed,$parser); + #$self->{'config'}->{'logger'}->log("Loaded dependency from $parser; $fetcher_needed"); FIXME add debug in config? + } else { + die "Failed to load $fetcher_needed, needed by $parser"; + } + } else { + my $toinit = $config->get_fetcher_module($fetcher_needed); + if(defined($toinit)) { + unless($self->dyninit($toinit,$fetcher_needed,$parser)) { + $self->{'config'}->{'logger'}->log("Dyninit failed for $fetcher_needed for $parser"); + } + } else { + $self->{'config'}->{'logger'}->log('No dyninit needed for '.$fetcher_needed.' for '.$parser); + } + } + } else { + $self->{'config'}->{'logger'}->log("Failed to load parser for $parser"); + } + } + return 1; +} + +sub dyninit { + my $self = shift; + my $fetcher_loaded = shift; + my $fetcher_needed = shift; + my $parser = shift; + if(my $initr = $self->init($fetcher_loaded,$parser)) { + $self->{'config'}->set('fetchers',$fetcher_needed,$fetcher_loaded); + #$self->{'config'}->{'logger'}->log("Dependency $fetcher_needed initialized"); FIXME add debug in config? + return 1; + } + return 0; +} + +sub load { + my $self = shift; + my $config = $self->{'config'}; + my $parser = shift; + my $filename = 'My/parser/'.$parser.'.pm'; + my $newclass; + eval { + require $filename; + my $classname = 'My::parser::'.$parser; + $newclass = $classname->new($config) || die "Failed to load parser for $parser"; + $self->{'config'}->{'logger'}->log("Loaded $classname"); + } or do { + my $e = $@; + $self->{'config'}->{'logger'}->log("Failed to load $filename: $e"); + return 0; + }; + $config->{'loadedparsers'}->{$parser} = 'loaded'; + return $newclass; +} + +sub init { + my $self = shift; + my $fetcher = shift; + my $parser = shift; + return 1 if($fetcher->init($parser)); + return 0; +} + +1; diff --git a/lib/ssh.pm b/lib/ssh.pm new file mode 100644 index 0000000..ac06734 --- /dev/null +++ b/lib/ssh.pm @@ -0,0 +1,59 @@ +package My::parser::ssh; +use strict; +use DBI; +use warnings; +use My::parser::ssh_parser; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + $self->{'config'} = shift; + $self->{'parser'} = My::parser::ssh_parser->new(); + return $self; +} + +sub fetch { + my $self = shift; + $self->{'dbh'} = $self->{'config'}->get_dbh unless($self->{'dbh'}); + my $seq = $self->{'seq'} || 0; + my $retmsg; + my @toreturn; + unless($seq) { + my $seqsth = $self->{'dbh'}->prepare("SELECT seq FROM logs WHERE program = 'sshd' ORDER BY seq DESC LIMIT 1") or $retmsg = DBI::errstr; + $seqsth->execute or $retmsg = DBI::errstr unless($retmsg); + $seq = $seqsth->fetchrow_arrayref->[0] or $retmsg = DBI::errstr unless($retmsg); + } + return { retval => 0, retmsg => $retmsg, error => 1 } if($retmsg); + my $sth = $self->{'dbh'}->prepare("SELECT msg,seq FROM logs WHERE program = 'sshd' AND seq > $seq") or $retmsg = DBI::errstr unless($retmsg); + $sth->execute or $retmsg = DBI::errstr unless($retmsg); + while(my $ref = $sth->fetchrow_hashref) { + my $string = $$ref{'msg'}; + $seq = $$ref{'seq'}; + push(@toreturn,$string); + } + $self->{'seq'} = $seq; + return { retval => 0, retmsg => $retmsg, error => 1 } if($retmsg); + return { retval => 0, retmsg => 'Nothing to return' } unless(scalar(@toreturn)); + return { retval => 1, retmsg => 'Here comes the results', lines => \@toreturn }; +} + +sub parse { + my $self = shift; + my @result; + my $string = $self->fetch; + if($string->{'retval'}) { + foreach my $str(@{$string->{'lines'}}) { + my $r = $self->{'parser'}->parser($str); + if($r->{'retval'}) { + delete $r->{'retval'}; + push(@result,$r); + } + } + return { retval => 1, retmsg => 'Here comes the results', lines => \@result }; + } + return { retval => 0, retmsg => "ssh-fetcher returned an error: $string->{'retmsg'}" } if($string->{'error'}); + return { retval => 0 }; # nothing to return, nothing to say:) +} + +1; diff --git a/lib/ssh_parser.pm b/lib/ssh_parser.pm new file mode 100644 index 0000000..80dc181 --- /dev/null +++ b/lib/ssh_parser.pm @@ -0,0 +1,178 @@ +package My::parser::ssh_parser; +use strict; +use warnings; + +sub new { + my $class = shift; + my $self = {}; + bless ($self, $class); + return $self; +} + +sub parser { + my $self = shift; + my $string = shift; + my ($reply,$hostile,$host) = ('',0,''); + my $re_host = qr/[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/; + my $re_user = qr/[\w\d_\-.]*/; + if($string =~ m/Accepted publickey for/) { + $reply = 'Accept publickey'; + } elsif($string =~ m/Received disconnect from $re_host port [0-9]{1,5}:11: disconnected by user/) { + $reply = 'Normal disconnect'; + } elsif($string =~ m/Failed unknown for (invalid user |)$re_user from $re_host port [0-9]{1,5} ssh2/) { + $reply = 'Log spam, also logged as a failure'; + } elsif($string =~ m/Disconnected from user $re_user $re_host port [0-9]{1,5}$/) { + $reply = 'Normal disconnect'; + } elsif($string =~ m/Did not receive identification string from /) { + $_ = $string; + $reply = 'No identification string'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/User $re_user from $re_host not allowed because /) { + $_ = $string; + $reply = 'Blocked user'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/(i|I)nvalid user .* from $re_host port/) { + $_ = $string; + $reply = 'Invalid user'; + $hostile = 1; + PARSER: + m/(from\ ($re_host)) /gcix && do { + $host = $2; + }; + } elsif($string =~ m/(Disconnecting|Received disconnect from|Disconnected from|Connection closed by|Connection reset by) (authenticating |invalid |)(user .* |)$re_host port [0-9]{1,6}.*\[preauth\]/) { + $_ = $string; + $reply = 'Received disconnect'; + $hostile = 1; + PARSER: + m/\ ($re_host)\ /gcix && do { + $host = $1; + }; + } elsif($string =~ m/refused connect from .* \($re_host\)/) { + $_ = $string; + $reply = 'Blocked by tcpwrappers'; + $hostile = 1; + PARSER: + m/ \(($re_host)\) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/error: maximum authentication attempts exceeded for (invalid user |)$re_user from ($re_host) port [0-9]{1,6} (ssh2 |)\[preauth\]/) { + $_ = $string; + $reply = 'Auth attempt limit exceeded'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/fatal: Unable to negotiate with/) { + $_ = $string; + $reply = 'Unable to negotiate'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Bad protocol version identification/) { + $_ = $string; + $reply = 'Bad protocol'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Could not write ident string to/) { + $_ = $string; + $reply = 'Could not write ident string'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Disconnecting (authenticating|invalid) user.*Change of username or service not allowed/) { + $_ = $string; + $reply = "Change of username or service"; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Failed publickey for $re_user from $re_host port [\d]{1,6}.*/) { + $_ = $string; + $reply = 'Failed publickey'; + $hostile = 1; + PARSER: + m/\ ($re_host)\ /gcix && do { + $host = $1; + }; + } elsif($string =~ m/ssh_dispatch_run_fatal/) { + $_ = $string; + $reply = 'ssh dispatch fatal'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Unable to negotiate with $re_host port/) { + $_ = $string; + $reply = 'Unable to negotiate. Weak key exchange'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Protocol major versions differ for/) { + $_ = $string; + $reply = 'Protocol major versions differ'; + $hostile = 1; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/Unable to negotiate with .* no matching MAC found/) { + $host = ''; + $reply = 'no matching MAC, ancient client trying to connect'; + } elsif($string =~ m/\/etc\/hosts\.allow/) { + $host = ''; + } elsif($string =~ m/Disconnecting: Too many authentication failures/) { + $host = ''; + } elsif($string =~ m/input_userauth_request: invalid user.*\[preauth\]/) { + $host = ''; + } elsif($string =~ m/user $re_user login class/) { + $host = ''; + $reply = 'Useless log info'; + } elsif($string =~ m/(Disconnected|Connection closed) (from|by) (invalid|) user $re_user $re_host port [0-9]{1,6} \[preauth\]/) { + $host = ''; + $reply = 'Log info'; + } elsif($string =~ m/Fssh_kex_exchange_identification/) { + $hostile = 0; + $reply = 'kex exchange identification problem'; + } elsif($string =~ m/fatal: Timeout before authentication for $re_host port [0-9]{1,6}/) { + $_ = $string; + $hostile = 1; + $reply = 'Timeout before auth'; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } elsif($string =~ m/banner exchange: Connection from $re_host port [0-9]{1,6}: invalid format/) { + $_ = $string; + $hostile = 1; + $reply = 'Invalid format during banner exchange'; + PARSER: + m/ ($re_host) /gcix && do { + $host = $1; + }; + } else { + $reply = 'No match for '.$string; + } + return { retval => 1, retmsg => $reply, hostile => $hostile, host => $host, string => $string }; +} + +1; diff --git a/lib/stats.pm b/lib/stats.pm new file mode 100644 index 0000000..016b171 --- /dev/null +++ b/lib/stats.pm @@ -0,0 +1,90 @@ +package My::parser::stats; +use strict; +use warnings; +use DBI; + +sub new { + my $class = shift; + my $config = shift; + my $self = {}; + bless ($self, $class); + $self->{'config'} = $config; + my $short_time = $config->get_as_single_val('config','short') || die "Failed to get short value"; + my $long_time = $config->get_as_single_val('config','long') || die "Failed to get long value"; + $self->{'short'} = $short_time; + $self->{'long'} = $long_time; + return $self; +} + +sub recent_hostile { + my $self = shift; + $self->{'dbh'} = $self->{'config'}->get_dbh unless($self->{'dbh'}); + my $sth = $self->{'dbh'}->prepare(" + SELECT COUNT(1) + FROM reject + WHERE time > DATE_SUB(NOW(), INTERVAL 5 MINUTE)") + || return { retval => 0, retmsg => 'Failed checking for rejects last 5 minutes: '.DBI::errstr }; + $sth->execute() || return { retval => 0, retmsg => 'Failed execute on checking for rejects last 5 minutes: '.DBI::errstr }; + my $rows = $sth->fetchrow_arrayref->[0]; + return { retval => 1, rows => $rows, retmsg => $rows.' the last 5 minutes' }; +} + +sub checker { + my $self = shift; + my $tocheck = shift; + my $host = $tocheck->{'host'}; + my $asn = $tocheck->{'asn'}; + my $iso = $tocheck->{'iso'}; + unless($self && $host) { + return { retval => 0, retmsg => "Too few variables to run My::parser::stats->checker, got host($host), asn($asn), iso($iso)" }; + } + my $retval; + my $rows; + my $msg; + my $return = { retval => 1 }; + my @times = ($self->{'short'},$self->{'long'}); + #### Mapping between checks and tables in db + my %checks = ('reject' => 'reject','blocks' => 'reject_iptables','iso' => 'reject','asn' => 'reject', 'block_iso' => 'reject_iptables', 'block_asn' => 'reject_iptables'); + #### Mapping between checks and column in db + my %values = ('reject' => 'ip','blocks' => 'ip', 'iso' => 'iso', 'asn' => 'asn', 'block_iso' => 'iso', 'block_asn' => 'asn'); + my %keys = ('reject' => $host, 'blocks' => $host, 'iso' => $iso, 'block_iso' => $iso, 'asn' => $asn, 'block_asn' => $asn); + $self->{'dbh'} = $self->{'config'}->get_dbh unless($self->{'dbh'}); + foreach my $time(@times) { + foreach my $c(keys %checks) { + my $fromcheck = $self->check($keys{$c},$time,$values{$c},$checks{$c}); + if($fromcheck->{'retval'}) { + my $temp = $fromcheck->{'rows'}; + $return->{$time}->{$c} = $temp; +# } else { +# $self->{'config'}->{'logger'}->log("Check failed: $fromcheck->{'retmsg'}"); + } + } + } + return $return; +} + +sub check { + my $self = shift; + my $host = shift; + my $allowed_time = shift; + my $value = shift; + my $table = shift; + my $retval; + my $rows; + my $msg; + if($host && $allowed_time && $value && $table) { + my $sth = $self->{'dbh'}->prepare(" + SELECT COUNT(1) + FROM $table + WHERE $value = ? AND + time > DATE_SUB(NOW(), INTERVAL ? MINUTE)") + || return { retval => 0, retmsg => "Checking $host in $table for $value in the last $allowed_time minutes failed: ".DBI::errstr }; + $sth->execute($host,$allowed_time) || return { retval => 0, retmsg => "Execute failed on $table, checking for $host in $allowed_time: ".DBI::errstr }; + my $rows = $sth->fetchrow_arrayref->[0]; + return { retval => 1, rows => $rows, retmsg => "$rows in $allowed_time minutes" }; + } else { + return { retval => 0, retmsg => "Check called with too few arguments, we got host: $host, allowed_time: $allowed_time, value: $value, table: $table" }; + } +} + +1;