#!/usr/local/bin/perl ################################################################### # # Copyright 2001, Brandon Gillespie # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # ################################################################### # # Usage: tail -f snortlog/alert | perl pigsentry # # Where snortlog/alert is a full format snort alert file (snort -A full) # Skim through the section entitled 'stuff you can change' # ############### # # VERSION: 1.1 [02-Oct-2001] Changes since 1.0: # * pigstate file is not backwards compatible, sorry... # * improved the trend handling bits # * classified notification into alert and warn status. # * a throttle for a rapidly increasing spike to send less notifies # * a pidfile manager # * added an example notify hook for sending email # VERSION: 1.0 [26-Sep-2001] Original release # ############### # # Notes: # # Currently only works on snort full format (-A full), blearg # Grr is my hero, watch Invader Zim on Nick (Awww, I wanted to explode) # use POSIX qw(mktime); ################################################################### ## stuff you can change # # the 'trend' management stuff is not as good as it could be. It should # do frequency analysis, but instead what I found works is to only average # when it see's activity. When the last poll interval was zero, it ignores. # this way the average/median value represents the event with activity, # and not all of the dead time inbetween (if it is very dead, it will expire # anyway). $trend_warn_throttle = 601; # how long since last trend alert, before # sending a new one? It is probably best # to have this greater than double the # trend poll interval, default=601 secs $trend_baseline_bump = 1.5; # if the median is less than this, lift # both the median and last hits up by this # amounts, then figure percentages. # Do not set to zero (div/0 errors). I # would not recommend putting this below 1 $trend_threshold_alert = 10; # Alert on a % spike over avg # default=10 (1000%) $trend_threshold_warn = 5; # Warn on a % spike over avg # default=5 (500%) $trend_poll = 300; # how many secs between trend intervals # default=5 minutes $trend_retention = 1*12; # how many intervals to keep in trend # stretching this may decrease notices # default=2 hours $state_checkpoint_interval = 300; # how often to checkpoint state table $state_expire_time = 86400; # when to expire data from state table $state_file = "/tmp/pigstate"; # state file $pid_file = "/tmp/pigsentry.pid"; # PID file ### regexp against the msg, to skip $ignore_rx = q/(fragments discarded|source quench|ICMP Destination Unreachable)/; ### notify hook, if defined will call this, otherwise will print to STDOUT ## no notify hook, use the default # $notify_hook = undef; ## a basic print to stdout hook select(STDOUT); $| = 1; $notify_hook = sub { my ($msg, $alert) = @_; (!$alert) && ($alert = "alert"); print ("[" . localtime() . "] $alert: $msg\n"); }; ## Perhaps you also want to send Email... # $recipients = "nobody@nowhere.com"; #$notify_hook = sub { # my ($msg, $alert) = @_; # (!$alert) && ($alert = "alert"); # system("/usr/bin/Mail -s \"$alert: $msg\" $recipients watch_log(fd): core engine, reads from log # # As time allows, calls check_state which updates trends and # expires entries. Also calls process_alert for each alert sub watch_log { my ($alert) = @_; my $buffer = ""; my $dohead = 0; # fragment checking, incase there was a tail -f while (<$alert>) { chomp; if (/^\[\*\*\]/) { $new = $_; if (length($buffer) && $dohead) { &process_alert($buffer); &check_state(); } $dohead=1; $buffer = $new; } else { $buffer .= "\f$_"; } } &process_alert($buffer); } ############################ # => load_state(): called at initialization to load stored state info # # initialize %alerts dictionary, in all its perly madness sub load_state { if (-f $state_file) { if (!open(STATE, $state_file)) { &error("Cannot open state file '$state_file': $!"); } else { while () { chomp; my @ary; my ($t, @rest) = split(/\t/); if ($t eq "a") { my ($last,$lwarn, $dtot,$ltot,$avg,$lavg,$a,@ary) = @rest; $alerts{$a} = {"last" => $last, "lwarn" => $lwarn, "dtot" => $dtot, "ltot" => $ltot, "avg" => $avg, "q" => \@ary, "lavg" => $lavg}; } } close(STATE); } } } ############################ # => store_pid(): write out a PID file # sub store_pid { if (!open(PID, ">$pid_file")) { &error("Cannot open PID file '$pid_file', skipping"); } else { print PID "$$\n"; close(PID); } }; ############################ # => store_state(): write out %alerts dictionary to disk # sub store_state { if (!open(STATE, ">$state_file")) { &error("Unable to open state file '>$state_file': $!"); } else { print STATE "v $pigsentry_version\n"; for $k (keys %alerts) { print STATE ("a\t" . $alerts{$k}->{"last"} . "\t" . $alerts{$k}->{"lwarn"} . "\t" . $alerts{$k}->{"dtot"} . "\t" . $alerts{$k}->{"ltot"} . "\t" . $alerts{$k}->{"avg"} . "\t" . $alerts{$k}->{"lavg"} . "\t$k\t"); my $ref = $alerts{$k}->{'q'}; print STATE join("\t", @$ref); print STATE "\n"; } $last_state_checkpoint = time(); close(STATE); } } ############################ # => store_and_exit(): call store_state() then exit # sub store_and_exit { $debug && (print "Storing state..."); &store_state(); unlink($pid_file); $debug && (print "goodbye\n"); exit(0); } ############################ # => error(msg): we had a booboo # sub error { print ("ERROR: " . $_[0] . "\n"); } ############################ # => notify(msg): send a notice about an event or trend # sub notify { if ($notify_hook) { &$notify_hook(@_); } else { print ("PigSentry: [" . localtime() . "] " . $_[0] . "\n"); } } ############################ # => check_state(): manages trends, alert expiration and checkpoints # # This is called at random times, and while there are defined minimum # intervals for various things, it is not guaranteed of there are no # new alerts coming out of snort. sub check_state { my $k; if ((time() - $last_state_checkpoint) > $state_checkpoint_interval) { &store_state(); } if ((time() - $last_state_check) < 60) { return; } $last_state_check = time(); for $k (keys (%alerts)) { if ((time() - $alerts{$k}->{'last'}) > $state_expire_time) { ## expire it... delete($alerts{$k}); } elsif ((time() - $alerts{$k}->{'lavg'}) > $trend_poll) { ## time for a new average interval... my $last = $alerts{$k}->{'dtot'} - $alerts{$k}->{'ltot'}; if ($last == 0) { # NO, only do it when there is a change... next; } ## set the last avg compute time... $alerts{$k}->{'lavg'} = time(); ## trim the queue $lref = $alerts{$k}->{'q'}; while (($#$lref+1) > ($trend_retention-1)) { shift(@$lref); } ## last total = this total $alerts{$k}->{'ltot'} = $alerts{$k}->{'dtot'}; push(@$lref, $last); ## bump the median? my $increase = 0; my $median = $alerts{$k}->{'avg'}; if ($median < $trend_baseline_bump) { $median += $trend_baseline_bump; $last += $trend_baseline_bump; } ## how long since our last alert? Don't be too noisy... if ((time() - $alerts->{'lwarn'}) > $trend_warn_throttle) { if ((($last - $median)/$median) >= $trend_threshold_warn) { $increase = (int((($last - $median)/$median)*100) . "%"); $msg = "Trend increase of $increase for $k"; if ((($last - $median)/$median) >= $trend_threshold_alert) { ¬ify($msg, "alert"); } else { ¬ify($msg, "warn"); } $alerts->{'lwarn'} = time(); } } ## lets figure a new average... my $sum = 0; my $p; for $p (@$lref) { $sum += $p; } my $navg = sprintf("%.2g", $sum / ($#$lref+1)) + 0; $alerts{$k}->{'avg'} = $navg; } } } ############################ # => add_to_state(msg,time,date,year): update an alert in the state table # # if the alert does not exist, then create a new data struct for it, # otherwise update the total and last time # requires POSIX:mktime sub add_to_state { my ($msg, $time, $date, $year) = @_; my ($h, $m, $s) = split(/:/, $time); my ($mon, $day) = split(/\//, $date); $t = mktime($s, $m, $h, $day, $mon-1, $year); if (!exists($alerts{$msg})) { ¬ify("New event: $msg", "alert"); my @ary = (); $alerts{$msg} = {"last" => $t, "lwarn" => 0, "dtot" => 1, "lavg" => 0, "q" => \@ary, "ltot" => 0, "avg" => 0}; } else { $alerts{$msg}->{"last"} = $t; $alerts{$msg}->{"dtot"}++; } } ############################ # => proc__ hooks, called by process_alert for specific known alert types # # mostly these are disregarded, but could be used in the future sub proc__portscan { } # => proc__miscalert: general alert, calls add_to_state() sub proc__miscalert { my ($msg, @rest) = @_; $omsg = $msg; $msg =~ s/\s*\[\*\*\]\s*//g; $msg =~ s/\s*\[([\d:]+)\]\s*//; $msg =~ s/^\s//; $id = $1; if ($msg eq "") { ## An alert fragment, ignore return; } my $cline = ""; my $sline = ""; my $pline = ""; if ($rest[0] =~ /\[Classification:/ || $rest[0] =~ /\[Priority/) { $cline = shift(@rest); } $sline = shift(@rest); $pline = shift(@rest); if ($sline =~ /([\d+\/]+)-([\d:]+)\.(\d+) ([\d:.]+) -> ([\d:.]+)/) { ($date, $time, $subtime, $from, $to) = ($1,$2,$3,$4,$5); } $proto = (split(/\s/, $pline))[0]; if (length($to)) { $detail = "$proto $from -> $to"; $to =~ s/:\d+$//; } &add_to_state($msg, $time, $date, (localtime())[5]); } ############################ # => process_alert(alert): chew on an alert and call the appropriate sub sub process_alert { my ($buf) = @_; my ($msg, @rest) = split(/\f/m, $buf); if ($msg =~ /$ignore_rx/i) { # stuff to skip } elsif ($msg =~ /spp_portscan/) { &proc__portscan($msg, @rest); } else { &proc__miscalert($msg, @rest); } }