File Coverage

blib/lib/Suricata/Monitoring.pm
Criterion Covered Total %
statement 35 209 16.7
branch 0 104 0.0
condition 0 21 0.0
subroutine 12 15 80.0
pod 3 3 100.0
total 50 352 14.2


line stmt bran cond sub pod time code
1             package Suricata::Monitoring;
2              
3 1     1   132613 use 5.006;
  1         4  
4 1     1   6 use strict;
  1         2  
  1         65  
5 1     1   7 use warnings;
  1         2  
  1         62  
6 1     1   1153 use JSON;
  1         17250  
  1         7  
7 1     1   276 use File::Path qw(make_path);
  1         2  
  1         112  
8 1     1   709 use File::ReadBackwards;
  1         3267  
  1         37  
9 1     1   7 use Carp;
  1         1  
  1         60  
10 1     1   714 use File::Slurp;
  1         48714  
  1         93  
11 1     1   833 use Time::Piece;
  1         16792  
  1         8  
12 1     1   997 use Hash::Flatten qw(:all);
  1         3906  
  1         178  
13 1     1   679 use MIME::Base64;
  1         1166  
  1         90  
14 1     1   877 use IO::Compress::Gzip qw(gzip $GzipError);
  1         53147  
  1         3733  
15              
16             =head1 NAME
17              
18             Suricata::Monitoring - LibreNMS JSON SNMP extend and Nagios style check for Suricata stats
19              
20             =head1 VERSION
21              
22             Version 1.0.0
23              
24             =cut
25              
26             our $VERSION = '1.0.0';
27              
28             =head1 SYNOPSIS
29              
30             use Suricata::Monitoring;
31              
32             my $args = {
33             mode => 'librenms',
34             drop_percent_warn => .75;
35             drop_percent_crit => 1,
36             error_delta_warn => 1,
37             error_delta_crit => 2,
38             error_ignore=>[],
39             files=>{
40             'ids'=>'/var/log/suricata/alert-ids.json',
41             'foo'=>'/var/log/suricata/alert-foo.json',
42             },
43             };
44              
45             my $sm=Suricata::Monitoring->new( $args );
46             my $returned=$sm->run;
47             $sm->print;
48             exit $returned->{alert};
49              
50             =head1 METHODS
51              
52             =head2 new
53              
54             Initiate the object.
55              
56             The args are taken as a hash ref. The keys are documented as below.
57              
58             The only must have is 'files'.
59              
60             - mode :: Wether the print_output output should be for Nagios or LibreNMS.
61             - value :: 'librenms' or 'nagios'
62             - Default :: librenms
63              
64             - drop_percent_warn :: Drop percent warning threshold.
65             - Default :: .75
66              
67             - drop_percent_crit :: Drop percent critical threshold.
68             - Default :: 1
69              
70             - error_delta_warn :: Error delta warning threshold. In errors/second.
71             - Default :: 1
72              
73             - error_delta_crit :: Error delta critical threshold. In errors/second.
74             - Default :: 2
75              
76             - max_age :: How far back to read in seconds.
77             - Default :: 360
78              
79             - files :: A hash with the keys being the instance name and the values
80             being the Eve files to read.
81              
82             my $args = {
83             mode => 'librenms',
84             drop_percent_warn => .75;
85             drop_percent_crit => 1,
86             error_delta_warn => 1,
87             error_delta_crit => 2,
88             max_age => 360,
89             error_ignore=>[],
90             files=>{
91             'ids'=>'/var/log/suricata/alert-ids.json',
92             'foo'=>'/var/log/suricata/alert-foo.json',
93             },
94             };
95              
96             my $sm=Suricata::Monitoring->new( $args );
97              
98             =cut
99              
100             sub new {
101 0     0 1   my %args;
102 0 0         if ( defined( $_[1] ) ) {
103 0           %args = %{ $_[1] };
  0            
104             }
105              
106             # init the object
107 0           my $self = {
108             drop_percent_warn => .75,
109             drop_percent_crit => 1,
110             error_delta_warn => 1,
111             error_delta_crit => 2,
112             max_age => 360,
113             mode => 'librenms',
114             cache_dir => '/var/cache/suricata-monitoring/',
115             };
116 0           bless $self;
117              
118             # reel in the numeric args
119 0           my @num_args = ( 'drop_percent_warn', 'drop_percent_crit', 'error_delta_warn', 'error_delta_crit', 'max_age' );
120 0           for my $num_arg (@num_args) {
121 0 0         if ( defined( $args{$num_arg} ) ) {
122 0           $self->{$num_arg} = $args{$num_arg};
123 0 0         if ( $args{$num_arg} !~ /[0-9\.]+/ ) {
124 0           confess( '"' . $num_arg . '" with a value of "' . $args{$num_arg} . '" is not numeric' );
125             }
126             }
127             }
128              
129             # get the mode and make sure it is valid
130 0 0 0       if (
    0 0        
131             defined( $args{mode} )
132             && ( ( $args{mode} ne 'librenms' )
133             && ( $args{mode} ne 'nagios' ) )
134             )
135             {
136 0           confess( '"' . $args{mode} . '" is not a understood mode' );
137             } elsif ( defined( $args{mode} ) ) {
138 0           $self->{mode} = $args{mode};
139             }
140              
141             # make sure we have files specified
142 0 0 0       if ( ( !defined( $args{files} ) )
143             || ( !defined( keys( %{ $args{files} } ) ) ) )
144             {
145 0           confess('No files specified');
146             } else {
147 0           $self->{files} = $args{files};
148             }
149              
150             # pull in cache dir location
151 0 0         if ( defined( $args{cache_dir} ) ) {
152 0           $self->{cache_dir} = $args{cache_dir};
153             }
154              
155             # if the cache dir does not exist, try to create it
156 0 0         if ( !-d $self->{cache_dir} ) {
157             make_path( $self->{cache_dir} )
158             or confess(
159 0 0         '"' . $args{cache_dir} . '" does not exist or is not a directory and could not be create... ' . $@ );
160             }
161              
162 0           return $self;
163             } ## end sub new
164              
165             =head2 run
166              
167             This runs it and collects the data. Also updates the cache.
168              
169             This will return a LibreNMS style hash.
170              
171             my $returned=$sm->run;
172              
173             =cut
174              
175             sub run {
176 0     0 1   my $self = $_[0];
177              
178             # this will be returned
179 0           my $to_return = {
180             data => {
181             totals => { drop_percent => 0, error_delta => 0 },
182             instances => {},
183             alert => 0,
184             alertString => ''
185             },
186             version => 2,
187             error => 0,
188             errorString => '',
189             };
190              
191 0           my $previous;
192 0           my $previous_file = $self->{cache_dir} . '/stats.json';
193 0 0         if ( -f $previous_file ) {
194             #
195 0           eval {
196 0           my $previous_raw = read_file($previous_file);
197 0           $previous = decode_json($previous_raw);
198             };
199 0 0         if ($@) {
200 0           $to_return->{error} = '1';
201             $to_return->{errorString}
202 0           = 'Failed to read previous JSON file, "' . $previous_file . '", and decode it... ' . $@;
203 0           $self->{results} = $to_return;
204 0           return $to_return;
205             }
206             } ## end if ( -f $previous_file )
207              
208             # figure out the time slot we care about
209 0           my $from = time;
210 0           my $till = $from - $self->{max_age};
211              
212             # process the files for each instance
213 0           my @instances = keys( %{ $self->{files} } );
  0            
214 0           my @alerts;
215 0           foreach my $instance (@instances) {
216              
217             # if we found it or not
218 0           my $found = 0;
219              
220             # ends processing for this file
221 0           my $process_it = 1;
222              
223             # open the file for reading it backwards
224 0           my $bw;
225 0           eval {
226             $bw = File::ReadBackwards->new( $self->{files}{$instance} )
227 0 0         or die( 'Can not read "' . $self->{files}{$instance} . '"... ' . $! );
228             };
229 0 0         if ($@) {
230 0           $to_return->{error} = '2';
231 0 0         if ( $to_return->{errorString} ne '' ) {
232 0           $to_return->{errorString} = $to_return->{errorString} . "\n";
233             }
234 0           $to_return->{errorString} = $to_return->{errorString} . $instance . ': ' . $@;
235 0           $process_it = 0;
236             }
237              
238             # get the first line, if possible
239 0           my $line;
240 0 0         if ($process_it) {
241 0           $line = $bw->readline;
242             }
243 0   0       while ( $process_it
244             && defined($line) )
245             {
246 0           eval {
247 0           my $json = decode_json($line);
248 0           my $timestamp = $json->{timestamp};
249 0           $timestamp =~ s/\.[0-9]*//;
250 0           my $t = Time::Piece->strptime( $timestamp, '%Y-%m-%dT%H:%M:%S%z' );
251             # stop process further lines as we've hit the oldest we care about
252 0 0         if ( $t->epoch <= $till ) {
253 0           $process_it = 0;
254             }
255              
256             # this is stats and we should be processing it, continue
257 0 0 0       if ( $process_it && defined( $json->{event_type} ) && $json->{event_type} eq 'stats' ) {
      0        
258             # an array that we don't really want
259 0           delete( $json->{stats}{detect}{engines} );
260 0           $found = 1;
261 0           $process_it = 0;
262             $to_return->{data}{instances}{$instance} = flatten(
263 0           \%{ $json->{stats} },
  0            
264             {
265             HashDelimiter => '__',
266             ArrayDelimiter => '@@@',
267             }
268             );
269             } ## end if ( $process_it && defined( $json->{event_type...}))
270             };
271              
272             # if we did not find it, error... either Suricata is not running or stats is not output interval for
273             # it is to low... needs to be under 5 minutes to function meaningfully for this
274 0 0 0       if ( !$found && !$process_it ) {
275             push( @alerts,
276             'Did not find a stats entry for instance "'
277             . $instance
278             . '" in "'
279             . $self->{files}{$instance}
280             . '" going back "'
281             . $self->{max_age}
282 0           . '" seconds' );
283             } ## end if ( !$found && !$process_it )
284              
285             # get the next line
286 0           $line = $bw->readline;
287             } ## end while ( $process_it && defined($line) )
288              
289             } ## end foreach my $instance (@instances)
290              
291             #
292             #
293             # put totals together
294             #
295             #
296 0           foreach my $instance (@instances) {
297 0           my @vars = keys( %{ $to_return->{data}{instances}{$instance} } );
  0            
298 0           foreach my $var (@vars) {
299             # remove it if is from a array that was missed
300 0 0         if ( $var =~ /\@\@\@/ ) {
301 0           delete( $to_return->{data}{instances}{$instance}{$var} );
302             } else {
303 0 0         if ( !defined( $to_return->{data}{totals}{$var} ) ) {
304 0           $to_return->{data}{totals}{$var} = $to_return->{data}{instances}{$instance}{$var};
305             } else {
306             $to_return->{data}{totals}{$var}
307 0           = $to_return->{data}{totals}{$var} + $to_return->{data}{instances}{$instance}{$var};
308             }
309             }
310             } ## end foreach my $var (@vars)
311             } ## end foreach my $instance (@instances)
312              
313             #
314             #
315             # process error deltas and and look for alerts
316             #
317             #
318 0           my @totals = keys( %{ $to_return->{data}{totals} } );
  0            
319 0           my @error_keys = ('file_store__fs_errors');
320 0           foreach my $item (@totals) {
321 0 0         if ( $item =~ /app_layer__error__[a-zA-Z0-9\-\_]+__gap/ ) {
322 0           push( @error_keys, $item );
323             }
324             }
325 0           foreach my $item (@error_keys) {
326 0           my $delta = $previous->{data}{totals}{$item} - $to_return->{data}{totals}{$item};
327             # if less than zero, then it has been restarted or clicked over
328 0 0         if ( $delta < 0 ) {
329 0           $delta = $to_return->{data}{totals}{$item};
330             }
331 0           $to_return->{data}{totals}{error_delta} = $to_return->{data}{totals}{error_delta} + $delta;
332             # this expects to work in 5 minute increments so convert to errors per second
333 0 0         if ( $delta != 0 ) {
334 0           $to_return->{data}{totals}{error_delta} = $to_return->{data}{totals}{error_delta} + $delta;
335             }
336 0 0         if ( $delta >= $self->{error_delta_crit} ) {
    0          
337 0 0         if ( $to_return->{data}{alert} < 2 ) {
338 0           $to_return->{data}{alert} = 2;
339             }
340 0           push( @alerts, 'CRITICAL - ' . $item . ' has a error delta greater than ' . $self->{error_delta_crit} );
341             } elsif ( $delta >= $self->{error_delta_warn} ) {
342 0 0         if ( $to_return->{data}{alert} < 1 ) {
343 0           $to_return->{data}{alert} = 1;
344             }
345 0           push( @alerts, 'WARNING - ' . $item . ' has a error delta greater than ' . $self->{error_delta_warn} );
346             }
347             } ## end foreach my $item (@error_keys)
348             # this expects to work in 5 minute increments so convert to errors per second
349 0 0         if ( $to_return->{data}{totals}{error_delta} != 0 ) {
350 0           $to_return->{data}{totals}{error_delta} = $to_return->{data}{totals}{error_delta} / 300;
351             }
352 0 0         if ( $to_return->{data}{totals}{error_delta} >= $self->{error_delta_crit} ) {
    0          
353 0 0         if ( $to_return->{data}{alert} < 2 ) {
354 0           $to_return->{data}{alert} = 2;
355             }
356             push( @alerts,
357             'CRITICAL - total error delta, '
358             . $to_return->{data}{totals}{error_delta}
359             . ', greater than '
360 0           . $self->{error_delta_crit} );
361             } elsif ( $to_return->{data}{totals}{error_delta} >= $self->{error_delta_warn} ) {
362 0 0         if ( $to_return->{data}{alert} < 1 ) {
363 0           $to_return->{data}{alert} = 1;
364             }
365             push( @alerts,
366             'WARNING - total error delta, '
367             . $to_return->{data}{totals}{error_delta}
368             . ', greater than '
369 0           . $self->{error_delta_warn} );
370             } ## end elsif ( $to_return->{data}{totals}{error_delta...})
371              
372             #
373             #
374             # process drop precent and and look for alerts
375             #
376             #
377 0           my @drop_keys = ( 'capture__kernel_drops', 'capture__kernel_ifdrops', 'capture__kernel_drops_any' );
378             # if this previous greater than or equal, almost certain it rolled over or restarted, so detla is zero
379 0           my $delta = $to_return->{data}{totals}{capture__kernel_packets};
380 0 0         if ( defined( $previous->{data}{totals}{capture__kernel_packets} ) ) {
381             $delta
382 0           = $to_return->{data}{totals}{capture__kernel_packets} - $previous->{data}{totals}{capture__kernel_packets};
383             }
384 0           $to_return->{data}{totals}{capture__kernel_drops_any} = 0;
385 0 0         if (defined($to_return->{data}{totals}{capture__kernel_drops})) {
386 0           $to_return->{data}{totals}{capture__kernel_drops_any} += $to_return->{data}{totals}{capture__kernel_drops};
387             }
388 0 0         if (defined($to_return->{data}{totals}{capture__kernel_ifdrops})) {
389 0           $to_return->{data}{totals}{capture__kernel_drops_any} += $to_return->{data}{totals}{capture__kernel_ifdrops};
390             }
391             # if delta is 0, then there previous is zero
392 0           foreach my $item (@drop_keys) {
393 0           my $drop_delta = 0;
394 0 0         if ( $delta > 0 ) {
395 0 0         if ( defined( $previous->{data}{totals}{$item} ) ) {
396 0           $drop_delta = $to_return->{data}{totals}{$item} - $previous->{data}{totals}{$item};
397             } else {
398 0           $drop_delta = $to_return->{data}{totals}{$item};
399             }
400             } else {
401 0 0         if (defined($to_return->{data}{totals}{$item})) {
402             # delta is zero, it has restarted or rolled over
403 0           $drop_delta = $to_return->{data}{totals}{$item};
404             }
405             }
406 0 0         if ( $drop_delta > 0 ) {
407 0           my $drop_percent = $drop_delta / $delta;
408 0 0         if ( $to_return->{data}{totals}{drop_percent} < $drop_percent ) {
409 0           $to_return->{data}{totals}{drop_percent} = $drop_percent;
410             }
411 0 0         if ( $drop_percent >= $self->{drop_percent_crit} ) {
    0          
412 0 0         if ( $to_return->{data}{alert} < 2 ) {
413 0           $to_return->{data}{alert} = 2;
414             }
415             push( @alerts,
416             'CRITICAL - '
417             . $item
418             . ' for totals has a drop percent greater than '
419 0           . $self->{drop_percent_crit} );
420             } elsif ( $drop_percent >= $self->{drop_percent_warn} ) {
421 0 0         if ( $to_return->{data}{alert} < 1 ) {
422 0           $to_return->{data}{alert} = 1;
423             }
424             push( @alerts,
425             'WARNING - '
426             . $item
427             . ' for totals has a drop percent greater than '
428 0           . $self->{drop_percent_warn} );
429             } ## end elsif ( $drop_percent >= $self->{drop_percent_warn...})
430             } ## end if ( $drop_delta > 0 )
431             } ## end foreach my $item (@drop_keys)
432              
433             #
434             #
435             # create the error string
436             #
437             #
438 0           $to_return->{alertString} = join( "\n", @alerts );
439              
440             #
441             #
442             # write the cache file on out
443             #
444             #
445 0           eval {
446 0           my $new_cache = encode_json($to_return);
447 0           write_file( $previous_file, $new_cache );
448              
449 0           my $compressed_string;
450 0           gzip \$new_cache => \$compressed_string;
451 0           my $compressed = encode_base64($compressed_string);
452 0           $compressed =~ s/\n//g;
453 0           $compressed = $compressed . "\n";
454              
455 0 0         if ( length($compressed) > length($new_cache) ) {
456 0           write_file( $self->{cache_dir} . '/snmp', $new_cache );
457             } else {
458 0           write_file( $self->{cache_dir} . '/snmp', $compressed );
459             }
460             };
461 0 0         if ($@) {
462 0           $to_return->{error} = '1';
463 0           $to_return->{data}{alert} = '3';
464 0           $to_return->{errorString} = 'Failed to write new cache JSON and SNMP return files.... ' . $@;
465              
466             # set the nagious style alert stuff
467 0           $to_return->{alert} = '3';
468 0 0         if ( $to_return->{data}{alertString} eq '' ) {
469 0           $to_return->{data}{alertString} = $to_return->{errorString};
470             } else {
471 0           $to_return->{data}{alertString} = $to_return->{errorString} . "\n" . $to_return->{alertString};
472             }
473             } ## end if ($@)
474              
475 0           $self->{results} = $to_return;
476              
477 0           return $to_return;
478             } ## end sub run
479              
480             =head2 print_output
481              
482             Prints the output.
483              
484             $sm->print_output;
485              
486             =cut
487              
488             sub print_output {
489 0     0 1   my $self = $_[0];
490              
491 0 0         if ( $self->{mode} eq 'nagios' ) {
492 0 0         if ( $self->{results}{alert} eq '0' ) {
    0          
    0          
    0          
493 0           print "OK - no alerts\n";
494 0           return;
495             } elsif ( $self->{results}{alert} eq '1' ) {
496 0           print 'WARNING - ';
497             } elsif ( $self->{results}{alert} eq '2' ) {
498 0           print 'CRITICAL - ';
499             } elsif ( $self->{results}{alert} eq '3' ) {
500 0           print 'UNKNOWN - ';
501             }
502 0           my $alerts = $self->{results}{alertString};
503 0           chomp($alerts);
504 0           $alerts = s/\n/\, /g;
505 0           print $alerts. "\n";
506             } else {
507 0           print encode_json( $self->{results} ) . "\n";
508             }
509             } ## end sub print_output
510              
511             =head1 LibreNMS HASH
512              
513             + $hash{'alert'} :: Alert status.
514             - 0 :: OK
515             - 1 :: WARNING
516             - 2 :: CRITICAL
517             - 3 :: UNKNOWN
518              
519             =head1 AUTHOR
520              
521             Zane C. Bowers-Hadley, C<< >>
522              
523             =head1 BUGS
524              
525             Please report any bugs or feature requests to C, or through
526             the web interface at L. I will be notified, and then you'll
527             automatically be notified of progress on your bug as I make changes.
528              
529              
530              
531              
532             =head1 SUPPORT
533              
534             You can find documentation for this module with the perldoc command.
535              
536             perldoc Suricata::Monitoring
537              
538              
539             You can also look for information at:
540              
541             =over 4
542              
543             =item * RT: CPAN's request tracker (report bugs here)
544              
545             L
546              
547             =item * CPAN Ratings
548              
549             L
550              
551             =item * Search CPAN
552              
553             L
554              
555             =back
556              
557              
558             =head * Git
559              
560             L
561              
562             =item * Web
563              
564             L
565              
566             =head1 ACKNOWLEDGEMENTS
567              
568              
569             =head1 LICENSE AND COPYRIGHT
570              
571             This software is Copyright (c) 2024 by Zane C. Bowers-Hadley.
572              
573             This is free software, licensed under:
574              
575             The Artistic License 2.0 (GPL Compatible)
576              
577              
578             =cut
579              
580             1; # End of Suricata::Monitoring