File Coverage

blib/lib/Check/SuricataFlows.pm
Criterion Covered Total %
statement 29 187 15.5
branch 0 104 0.0
condition 0 48 0.0
subroutine 10 12 83.3
pod 2 2 100.0
total 41 353 11.6


line stmt bran cond sub pod time code
1             package Check::SuricataFlows;
2              
3 1     1   138204 use 5.006;
  1         5  
4 1     1   7 use strict;
  1         2  
  1         46  
5 1     1   8 use warnings;
  1         3  
  1         58  
6 1     1   678 use File::ReadBackwards ();
  1         3796  
  1         42  
7 1     1   1003 use JSON qw(decode_json);
  1         17460  
  1         7  
8 1     1   218 use Scalar::Util qw(looks_like_number);
  1         2  
  1         71  
9 1     1   858 use Time::Piece qw( localtime );
  1         18653  
  1         6  
10 1     1   835 use Regexp::IPv6 qw($IPv6_re);
  1         2064  
  1         169  
11 1     1   634 use Regexp::IPv4 qw($IPv4_re);
  1         378  
  1         131  
12 1     1   800 use NetAddr::IP ();
  1         39149  
  1         3206  
13              
14             =head1 NAME
15              
16             Check::SuricataFlows - Make sure Suricata is seeing data via reading the Suricata flows json
17              
18             =head1 VERSION
19              
20             Version 0.2.0
21              
22             =cut
23              
24             our $VERSION = '0.2.0';
25              
26             =head1 SYNOPSIS
27              
28             This reads the Suricata EVE JSON flow data file.
29              
30             .timestamp :: Used for double checking to make sure we don't read farther
31             back than we need to.
32              
33             If the following is found, the entry is checked.
34              
35             .dest_ip
36             .src_ip
37             .flow.pkts_toclient
38             .flow.pkts_toserver
39              
40             Bi-directional is when .flow.pkts_toclient and .flow.pkts_toserver are both greater
41             than zero.
42              
43             Uni-directional is when only .flow.pkts_toclient or .flow.pkts_toserver is greater
44             than zero and the other is zero.
45              
46             If all entries found are uni-directional then it is safe to assume the monitored span
47             is misconfigured.
48              
49             If sensor_names is used, then each of the specified sensors is checked for. It is
50             checked from .host in the JSON and the variable for setting that in the Suricata config
51             is .sensor-name .
52              
53             Example...
54              
55             use Check::SuricataFlows;
56             use Data::Dumper;
57              
58             my $flow_checker;
59             eval {
60             $flow_checker = Check::SuricataFlows->new(
61             max_lines => $max_lines,
62             read_back_time => $read_back_time,
63             warn_count => $warn_count,
64             alert_count => $alert_count,
65             flow_file => $flow_file,
66             );
67             };
68             if ($@) {
69             print 'Failed to call Check::SuricataFlows->new... ' . $@ . "\n";
70             exit 3;
71             }
72              
73             my $results;
74             eval { $results = $flow_checker->run; };
75             if ($@) {
76             print 'Failed to call $flow_checker->run... ' . $@ . "\n";
77             exit 3;
78             }
79              
80             print $results->{status};
81             exit $results->{status_code};
82              
83             =head1 METHODS
84              
85             =head2 new
86              
87             Initiates the object.
88              
89             - max_lines :: Maximum distance to read back. The sizing of this should be large enough to
90             ensure you get enought data that has bidirectional flow info. Likely need to increase for
91             very noisy networks.
92             default :: 500
93              
94             - read_back_time :: How far back to read in seconds. Set to 0 to disable.
95             default :: 300
96              
97             - warn_count :: Warn if it is less then this for bidirectional traffic.
98             default :: 20
99              
100             - alert_count :: Alert if it is less than this for bidirectional traffic.
101             default :: 10
102              
103             - flow_file :: The location json file containing the flow data.
104             default :: /var/log/suricata/flows/current/flow.json
105              
106             - sensor_names :: A array of sensor names to check for. If specified each of these need to be above
107             warn/alert count. If empty or undef, only no checking is done for sensor names, just totals.
108             default :: []
109              
110             - ignore_IPs :: A array of IPs to ignore.
111             default :: []
112              
113             Example...
114              
115             my $flow_checker;
116             eval {
117             $flow_checker = Check::SuricataFlows->new(
118             max_lines => $max_lines,
119             read_back_time => $read_back_time,
120             warn_count => $warn_count,
121             alert_count => $alert_count,
122             flow_file => $flow_file,
123             );
124             };
125             if ($@) {
126             print 'Failed to call Check::SuricataFlows->new... ' . $@ . "\n";
127             exit 3;
128             }
129              
130             =cut
131              
132             sub new {
133 0     0 1   my ( $blank, %opts ) = @_;
134              
135 0 0         if ( !defined( $opts{max_lines} ) ) {
    0          
    0          
    0          
136 0           $opts{max_lines} = 500;
137             } elsif ( ref( $opts{max_lines} ) ne '' ) {
138 0           die( '$opts{max_lines} of ref type "' . ref( $opts{max_lines} ) . '" and not ""' );
139             } elsif ( !looks_like_number( $opts{max_lines} ) ) {
140 0           die( '$opts{max_lines}, "' . $opts{max_lines} . '", does not appear to be numeric' );
141             } elsif ( $opts{max_lines} < 1 ) {
142 0           die( '$opts{max_lines}, "' . $opts{max_lines} . '", must be 1 or greater' );
143             }
144              
145 0 0         if ( !defined( $opts{read_back_time} ) ) {
    0          
    0          
    0          
146 0           $opts{read_back_time} = 300;
147             } elsif ( ref( $opts{read_back_time} ) ne '' ) {
148 0           die( '$opts{read_back_time} of ref type "' . ref( $opts{read_back_time} ) . '" and not ""' );
149             } elsif ( !looks_like_number( $opts{read_back_time} ) ) {
150 0           die( '$opts{read_back_time}, "' . $opts{read_back_time} . '", does not appear to be numeric' );
151             } elsif ( $opts{read_back_time} < 1 ) {
152 0           die( '$opts{read_back_time}, "' . $opts{read_back_time} . '", must be 1 or greater' );
153             }
154              
155 0 0         if ( !defined( $opts{warn_count} ) ) {
    0          
    0          
156 0           $opts{warn_count} = 20;
157             } elsif ( ref( $opts{warn_count} ) ne '' ) {
158 0           die( '$opts{warn_count} of ref type "' . ref( $opts{warn_count} ) . '" and not ""' );
159             } elsif ( !looks_like_number( $opts{warn_count} ) ) {
160 0           die( '$opts{warn_count}, "' . $opts{warn_count} . '", does not appear to be numeric' );
161             }
162              
163 0 0         if ( !defined( $opts{alert_count} ) ) {
    0          
    0          
164 0           $opts{alert_count} = 10;
165             } elsif ( ref( $opts{alert_count} ) ne '' ) {
166 0           die( '$opts{alert_count} of ref type "' . ref( $opts{alert_count} ) . '" and not ""' );
167             } elsif ( !looks_like_number( $opts{alert_count} ) ) {
168 0           die( '$opts{alert_count}, "' . $opts{alert_count} . '", does not appear to be numeric' );
169             }
170              
171 0 0         if ( !defined( $opts{flow_file} ) ) {
    0          
172 0           $opts{flow_file} = '/var/log/suricata/flows/current/flow.json';
173             } elsif ( ref( $opts{flow_file} ) ne '' ) {
174 0           die( '$opts{flow_file} of ref type "' . ref( $opts{flow_file} ) . '" and not ""' );
175             }
176              
177 0 0         if ( !defined( $opts{sensor_names} ) ) {
    0          
178 0           $opts{sensor_names} = [];
179             } elsif ( ref( $opts{sensor_names} ) ne 'ARRAY' ) {
180 0           die( '$opts{sensor_names} of ref type "' . ref( $opts{sensor_names} ) . '" and not "ARRAY"' );
181             } else {
182 0           my $sensor_names_int = 0;
183 0           while ( defined( $opts{sensor_names}[$sensor_names_int] ) ) {
184 0 0         if ( ref( $opts{sensor_names}[$sensor_names_int] ) ne '' ) {
185             die( '$opts{flow_file}['
186             . $sensor_names_int
187             . '] of ref type "'
188 0           . ref( $opts{sensor_names}[$sensor_names_int] )
189             . '" and not ""' );
190             }
191 0           $sensor_names_int++;
192             } ## end while ( defined( $opts{sensor_names}[$sensor_names_int...]))
193             } ## end else [ if ( !defined( $opts{sensor_names} ) ) ]
194              
195 0           my @ignore_IPs;
196 0           my $ignore_IPs_lookup = {};
197 0 0         if ( defined( $opts{ignore_IPs} ) ) {
198 0 0         if ( ref( $opts{ignore_IPs} ) ne 'ARRAY' ) {
199 0           die( '$opts{ignore_IPs} of ref type "' . ref( $opts{ignore_IPs} ) . '" and not "ARRAY"' );
200             }
201              
202 0           my $ignore_IPs_int = 0;
203 0           while ( defined( $opts{ignore_IPs}[$ignore_IPs_int] ) ) {
204 0 0         if ( ref( $opts{ignore_IPs}[$ignore_IPs_int] ) ne '' ) {
205             die( '$opts{ignore_IPs}['
206             . $ignore_IPs_int
207             . '] of ref type "'
208 0           . ref( $opts{ignore_IPs}[$ignore_IPs_int] )
209             . '" and not ""' );
210             }
211              
212 0 0 0       if ( ( $opts{ignore_IPs}[$ignore_IPs_int] !~ /^$IPv6_re$/ )
213             && ( $opts{ignore_IPs}[$ignore_IPs_int] !~ /^$IPv4_re$/ ) )
214             {
215             die( '$opts{ignore_IPs}['
216             . $ignore_IPs_int . '], "'
217 0           . $opts{ignore_IPs}[$ignore_IPs_int]
218             . '", does not appear to be IPv4 or IPv6' );
219             }
220 0           eval {
221 0           my $ip = NetAddr::IP->new( $opts{ignore_IPs}[$ignore_IPs_int] );
222 0           push( @ignore_IPs, $ip->addr );
223             };
224 0 0         if ($@) {
225             die( '$opts{ignore_IPs}['
226             . $ignore_IPs_int . '], "'
227 0           . $opts{ignore_IPs}[$ignore_IPs_int]
228             . '", could not be parsed... '
229             . $@ );
230             }
231              
232 0           $ignore_IPs_int++;
233             } ## end while ( defined( $opts{ignore_IPs}[$ignore_IPs_int...]))
234              
235 0           foreach my $ip (@ignore_IPs) {
236 0           $ignore_IPs_lookup->{$ip} = 1;
237             }
238             } ## end if ( defined( $opts{ignore_IPs} ) )
239              
240             my $self = {
241             flow_file => $opts{flow_file},
242             alert_count => $opts{alert_count},
243             warn_count => $opts{warn_count},
244             read_back_time => $opts{read_back_time},
245             max_lines => $opts{max_lines},
246             sensor_names => $opts{sensor_names},
247 0           ignore_IPs => \@ignore_IPs,
248             ignore_IPs_lookup => $ignore_IPs_lookup,
249             };
250 0           bless $self;
251              
252 0           return $self;
253             } ## end sub new
254              
255             =head2 run
256              
257             This method runs the check.
258              
259             Possible fatal errors such as the flow file not existing or being readable
260             results in a status_code of 3 being set and a description set in status.
261              
262             This returns a hash ref. The keys are as below.
263              
264             - status :: Status string for the results.
265              
266             - status_code :: Nagios style int. 0=OK, 1=WARN, 2=ALERT, 3=UNKNOWN/ERROR
267              
268             - lines_read :: Number of lines read.
269              
270             - lines_parsed :: Number of lines successfully parsed.
271              
272             - lines_get_errored :: Number of lines that resulted in fetch errors.
273              
274             - lines_get_errors :: A array of strings of errors encountered when getting the next flow entry to process.
275              
276             - bi_directional_count :: Count of bi-directional flows.
277              
278             - uni_directional_count :: Count of uni-directional flows.
279              
280             - ip_ignored_lines :: Lines ignored thanks to src/dest IP.
281              
282             - ip_parse_errored :: Lines in which the src/dest IP could not be parsed.
283              
284             - ip_parse_errors :: Array containing error info on src/dest IP c parsing issues.
285              
286             Example...
287              
288             my $results;
289             eval { $results = $flow_checker->run; };
290             if ($@) {
291             print 'Failed to call $flow_checker->run... ' . $@ . "\n";
292             exit 3;
293             }
294              
295             print $results->{status};
296             exit $results->{status_code};
297              
298             =cut
299              
300             sub run {
301 0     0 1   my $self = $_[0];
302              
303 0           my $to_return = {
304             status => '',
305             status_code => 0,
306             lines_read => 0,
307             lines_parsed => 0,
308             lines_get_errored => 0,
309             bi_directional_count => 0,
310             uni_directional_count => 0,
311             lines_get_errors => [],
312             by_sensor => {},
313             ip_ignored_lines => 0,
314             ip_parse_errored => 0,
315             ip_parse_errors => [],
316             };
317              
318             # can't go any further if we can't read the file
319 0 0         if ( !-e $self->{flow_file} ) {
    0          
320 0           $to_return->{status} = "flow file, '" . $self->{flow_file} . "', does not exist\n";
321 0           $to_return->{status_code} = 3;
322 0           return $to_return;
323             } elsif ( !-r $self->{flow_file} ) {
324 0           $to_return->{status} = "flow file, '" . $self->{flow_file} . "', not readable\n";
325 0           $to_return->{status_code} = 3;
326 0           return $to_return;
327             }
328              
329             # get the current time and figure when we should read back till
330 0           my $read_till = localtime;
331 0           $read_till = $read_till - $self->{read_back_time};
332              
333             # init readbackwards and if we can't we can't go any further
334 0           my $bw;
335 0           eval { $bw = File::ReadBackwards->new( $self->{flow_file} )
336 0 0         or die "can't read '" . $self->{flow_file} . "' $!"; };
337 0 0         if ($@) {
338             $to_return->{status}
339 0           = "Failed to initiate File::ReadBackwards for '" . $self->{flow_file} . "' ... " . $@ . "\n";
340 0           $to_return->{status_code} = 3;
341 0           return $to_return;
342             }
343              
344             # as long as this is true, we should continue reading the file
345 0           my $process = 1;
346             # for keeping track of number of lines read
347 0           my $line_count;
348 0           while ($process) {
349 0           $line_count++;
350 0 0         if ( $line_count > $self->{max_lines} ) {
    0          
351 0           $process = 0;
352             } elsif ( $bw->eof ) {
353 0           $process = 0;
354             }
355              
356 0 0         if ($process) {
357 0           my $parsed_line;
358 0           my $process_line = 0;
359 0           eval {
360 0           my $line = $bw->readline;
361              
362 0           $to_return->{lines_read}++;
363              
364 0           $parsed_line = decode_json($line);
365              
366 0           $to_return->{lines_parsed}++;
367              
368 0           $process_line = 1;
369             };
370 0 0         if ($@) {
    0          
371 0           push( @{ $to_return->{lines_get_errors} }, $@ );
  0            
372 0           $to_return->{lines_get_errored}++;
373             } elsif ($process_line) {
374 0           my $time_good = 0;
375              
376 0 0         if ( defined( $parsed_line->{'timestamp'} ) ) {
377 0           $parsed_line->{'timestamp'} =~ s/\.[0-9]+//;
378              
379 0           my $time;
380 0           eval { $time = Time::Piece->strptime( $parsed_line->{'timestamp'}, '%Y-%m-%dT%T%z' ); };
  0            
381 0 0 0       if ( defined($time) && $time >= $read_till ) {
    0 0        
382 0           $time_good = 1;
383             } elsif ( defined($time) && $time < $read_till ) {
384 0           $process = 0;
385             }
386             } ## end if ( defined( $parsed_line->{'timestamp'} ...))
387              
388 0 0 0       if ( $time_good
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
      0        
389             && defined( $parsed_line->{'dest_ip'} )
390             && ( ref( $parsed_line->{'dest_ip'} ) eq '' )
391             && defined( $parsed_line->{'src_ip'} )
392             && ( ref( $parsed_line->{'src_ip'} ) eq '' )
393             && defined( $parsed_line->{'flow'} )
394             && ( ref( $parsed_line->{'flow'} ) eq 'HASH' )
395             && defined( $parsed_line->{'flow'}{'pkts_toclient'} )
396             && ( ref( $parsed_line->{'flow'}{'pkts_toclient'} ) eq '' )
397             && looks_like_number( $parsed_line->{'flow'}{'pkts_toclient'} )
398             && defined( $parsed_line->{'flow'}{'pkts_toserver'} )
399             && ( ref( $parsed_line->{'flow'}{'pkts_toserver'} ) eq '' )
400             && looks_like_number( $parsed_line->{'flow'}{'pkts_toserver'} ) )
401             {
402 0           my $IP_ignore = 0;
403              
404 0           eval {
405 0           my $src_ip = NetAddr::IP->new( $parsed_line->{'src_ip'} );
406 0 0         if ( $self->{ignore_IPs_lookup}{ $src_ip->addr } ) {
407 0           $IP_ignore = 1;
408             }
409 0 0         if ( !$IP_ignore ) {
410 0           my $dest_ip = NetAddr::IP->new( $parsed_line->{'dest_ip'} );
411 0 0         if ( $self->{ignore_IPs_lookup}{ $dest_ip->addr } ) {
412 0           $IP_ignore = 1;
413             }
414             }
415             };
416 0 0         if ($@) {
417 0           push( @{ $to_return->{ip_parse_errors} }, $@ );
  0            
418 0           $to_return->{ip_parse_errored}++;
419             }
420              
421 0 0         if ( !$IP_ignore ) {
422 0           my $sensor = $parsed_line->{'host'};
423 0 0         if ( !defined( $to_return->{by_sensor}{$sensor} ) ) {
424 0           $to_return->{by_sensor}{$sensor} = {
425             bi_directional_count => 0,
426             uni_directional_count => 0,
427             };
428             }
429 0 0 0       if ( ( $parsed_line->{'flow'}{'pkts_toserver'} > 0 )
430             && ( $parsed_line->{'flow'}{'pkts_toclient'} > 0 ) )
431             {
432 0           $to_return->{bi_directional_count}++;
433 0           $to_return->{by_sensor}{$sensor}{bi_directional_count}++;
434             } else {
435 0           $to_return->{uni_directional_count}++;
436 0           $to_return->{by_sensor}{$sensor}{uni_directional_count}++;
437             }
438             } else {
439 0           $to_return->{ip_ignored_lines}++;
440             }
441             } ## end if ( $time_good && defined( $parsed_line->...))
442             } ## end elsif ($process_line)
443             } ## end if ($process)
444             } ## end while ($process)
445              
446 0 0         if ( $to_return->{bi_directional_count} <= $self->{alert_count} ) {
    0          
447             $to_return->{status}
448             = 'ALERT: bi directional count of '
449             . $to_return->{bi_directional_count}
450             . ' is less than '
451 0           . $self->{alert_count} . "\n";
452 0           $to_return->{status_code} = 2;
453             } elsif ( $to_return->{bi_directional_count} <= $self->{warn_count} ) {
454             $to_return->{status}
455             = 'WARN: bi directional count of '
456             . $to_return->{bi_directional_count}
457             . ' is less than '
458 0           . $self->{warn_count} . "\n";
459 0           $to_return->{status_code} = 1;
460             } else {
461 0           $to_return->{status} = 'OK: bi directional count of ' . $to_return->{bi_directional_count} . "\n";
462             }
463              
464 0 0         if ( defined( $self->{sensor_names}[0] ) ) {
465 0           foreach my $sensor ( @{ $self->{sensor_names} } ) {
  0            
466 0 0         if ( defined( $to_return->{by_sensor}{$sensor} ) ) {
467 0 0         if ( $to_return->{by_sensor}{$sensor}{bi_directional_count} <= $self->{alert_count} ) {
    0          
468             $to_return->{status}
469             = $to_return->{status}
470             . 'ALERT, '
471             . $sensor
472             . ': bi directional count of '
473             . $to_return->{by_sensor}{$sensor}{bi_directional_count}
474             . ' is less than '
475 0           . $self->{alert_count} . "\n";
476 0           $to_return->{status_code} = 2;
477             } elsif ( $to_return->{by_sensor}{$sensor}{bi_directional_count} <= $self->{warn_count} ) {
478             $to_return->{status}
479             = $to_return->{status}
480             . 'WARN, '
481             . $sensor
482             . ': bi directional count of '
483             . $to_return->{by_sensor}{$sensor}{bi_directional_count}
484             . ' is less than '
485 0           . $self->{warn_count} . "\n";
486 0 0         if ( $to_return->{status_code} < 1 ) {
487 0           $to_return->{status_code} = 1;
488             }
489             } else {
490             $to_return->{status}
491             = $to_return->{status} . 'OK, '
492             . $sensor
493             . ': bi directional count of '
494 0           . $to_return->{by_sensor}{$sensor}{bi_directional_count} . "\n";
495             }
496             } else {
497 0           $to_return->{status} = $to_return->{status} . 'ALERT, ' . $sensor . ": sensor never seen\n";
498 0           $to_return->{status_code} = 2;
499             }
500             } ## end foreach my $sensor ( @{ $self->{sensor_names} })
501             } ## end if ( defined( $self->{sensor_names}[0] ) )
502              
503 0           $to_return->{status} = $to_return->{status} . 'uni directional: ' . $to_return->{uni_directional_count} . "\n";
504 0           foreach my $sensor ( @{ $self->{sensor_names} } ) {
  0            
505 0 0         if ( defined( $to_return->{by_sensor}{$sensor} ) ) {
506             $to_return->{status}
507             = $to_return->{status}
508             . 'uni directional, '
509             . $sensor . ': '
510 0           . $to_return->{by_sensor}{$sensor}{uni_directional_count} . "\n";
511             } else {
512 0           $to_return->{status} = $to_return->{status} . 'uni directional, ' . $sensor . ": 0\n";
513             }
514             } ## end foreach my $sensor ( @{ $self->{sensor_names} })
515             $to_return->{status}
516 0           = $to_return->{status} . 'seen sensors: ' . join( ', ', keys( %{ $to_return->{by_sensor} } ) ) . "\n";
  0            
517             $to_return->{status}
518 0           = $to_return->{status} . 'ignored IPs: ' . join( ', ', @{ $self->{ignore_IPs} } ) . "\n";
  0            
519 0           $to_return->{status} = $to_return->{status} . 'max lines to read: ' . $self->{max_lines} . "\n";
520 0           $to_return->{status} = $to_return->{status} . 'lines read: ' . $to_return->{lines_read} . "\n";
521 0           $to_return->{status} = $to_return->{status} . 'lines parsed: ' . $to_return->{lines_parsed} . "\n";
522 0           $to_return->{status} = $to_return->{status} . 'line gets errored: ' . $to_return->{lines_get_errored} . "\n";
523 0           $to_return->{status} = $to_return->{status} . 'IP parse errored: ' . $to_return->{ip_parse_errored} . "\n";
524              
525 0           return $to_return;
526             } ## end sub run
527              
528             =head1 AUTHOR
529              
530             Zane C. Bowers-Hadley, C<< >>
531              
532             =head1 BUGS
533              
534             Please report any bugs or feature requests to C, or through
535             the web interface at L. I will be notified, and then you'll
536             automatically be notified of progress on your bug as I make changes.
537              
538              
539              
540              
541             =head1 SUPPORT
542              
543             You can find documentation for this module with the perldoc command.
544              
545             perldoc Check::SuricataFlows
546              
547              
548             You can also look for information at:
549              
550             =over 4
551              
552             =item * RT: CPAN's request tracker (report bugs here)
553              
554             L
555              
556             =item * CPAN Ratings
557              
558             L
559              
560             =item * Search CPAN
561              
562             L
563              
564             =back
565              
566              
567             =head1 ACKNOWLEDGEMENTS
568              
569              
570             =head1 LICENSE AND COPYRIGHT
571              
572             This software is Copyright (c) 2026 by Zane C. Bowers-Hadley.
573              
574             This is free software, licensed under:
575              
576             The GNU General Public License, Version 2, June 1991
577              
578              
579             =cut
580              
581             1; # End of Check::SuricataFlows