File Coverage

blib/lib/Masscan/Scanner.pm
Criterion Covered Total %
statement 65 224 29.0
branch 0 48 0.0
condition 0 31 0.0
subroutine 22 47 46.8
pod 5 5 100.0
total 92 355 25.9


line stmt bran cond sub pod time code
1             package Masscan::Scanner;
2 1     1   101798 use strict;
  1         2  
  1         31  
3 1     1   6 use warnings;
  1         2  
  1         26  
4 1     1   15 use v5.20;
  1         5  
5 1     1   605 use Moose;
  1         478428  
  1         8  
6 1     1   8348 use MooseX::AttributeShortcuts;
  1         509488  
  1         6  
7 1     1   139449 use MooseX::StrictConstructor;
  1         16830  
  1         4  
8 1     1   8795 use MooseX::Types::Moose qw(:all);
  1         3  
  1         19  
9 1     1   9768 use MooseX::Types::Structured qw(:all);
  1         389185  
  1         7  
10 1     1   682 use Carp;
  1         3  
  1         71  
11 1     1   12 use File::Spec;
  1         2  
  1         27  
12 1     1   992 use File::Temp;
  1         20333  
  1         73  
13 1     1   518 use IPC::Open3;
  1         2880  
  1         60  
14 1     1   7 use Symbol 'gensym';
  1         4  
  1         39  
15 1     1   739 use JSON;
  1         10891  
  1         5  
16 1     1   753 use Net::DNS;
  1         86649  
  1         304  
17 1     1   656 use Data::Validate::IP qw(is_ipv4 is_ipv6);
  1         46223  
  1         118  
18 1     1   538 use Data::Validate::Domain qw(is_domain);
  1         10216  
  1         68  
19 1     1   917 use Log::Log4perl qw(:easy);
  1         47622  
  1         7  
20 1     1   1219 use Log::Log4perl::Appender::ScreenColoredLevels::UsingMyColors;
  1         11684  
  1         35  
21 1     1   482 use Try::Catch;
  1         925  
  1         59  
22 1     1   639 use Data::Dumper;
  1         6421  
  1         68  
23 1     1   9 use namespace::autoclean;
  1         2  
  1         22  
24              
25             # ABSTRACT: A Perl module which helps in using the masscan port scanner.
26              
27             has hosts =>
28             (
29             is => 'rw',
30             isa => ArrayRef,
31             required => 0,
32             default => sub{[]},
33             );
34              
35             has ports =>
36             (
37             is => 'rw',
38             isa => ArrayRef,
39             required => 0,
40             default => sub{[]},
41             );
42              
43             has arguments =>
44             (
45             is => 'rw',
46             isa => ArrayRef,
47             required => 0,
48             default => sub{[]},
49             );
50              
51             has binary =>
52             (
53             is => 'rw',
54             isa => Str,
55             required => 0,
56             builder => 1,
57             lazy => 1,
58             );
59              
60             has command_line =>
61             (
62             is => 'rw',
63             isa => Str,
64             required => 0,
65             );
66              
67             has scan_results_file =>
68             (
69             is => 'rw',
70             isa => Str,
71             required => 0,
72             builder => 1,
73             lazy => 1,
74             );
75              
76             has sudo =>
77             (
78             is => 'rw',
79             isa => Int,
80             required => 0,
81             default => 0,
82             );
83              
84             has verbose =>
85             (
86             is => 'rw',
87             isa => Int,
88             required => 0,
89             default => 0,
90             );
91              
92             has logger =>
93             (
94             is => 'ro',
95             isa => Object,
96             required => 0,
97             builder => 1,
98             lazy => 1,
99             );
100              
101             has name_servers =>
102             (
103             is => 'rw',
104             isa => ArrayRef,
105             required => 0,
106             builder => 1,
107             );
108              
109             sub add_host
110             {
111 0     0 1   my $self = shift;
112 0           my $host = shift;
113              
114 0 0         return ($self->_is_valid_host($host)) ? push($self->hosts->@*, $host) : 0;
115             }
116              
117             sub add_port
118             {
119 0     0 1   my $self = shift;
120 0           my $port = shift;
121              
122 0 0         return ($self->_is_valid_port($port)) ? push($self->ports->@*, $port) : 0;
123             }
124              
125             sub add_argument
126             {
127 0     0 1   my $self = shift;
128 0           my $arg = shift;
129              
130 0           return push($self->arguments->@*, $arg);
131             }
132              
133             sub scan
134             {
135 0     0 1   my $self = shift;
136 0           my $binary = $self->binary;
137 0           my $hosts = $self->_aref_to_str($self->_hosts_to_ips($self->hosts), 'hosts');
138 0           my $ports = $self->_aref_to_str($self->ports, 'ports');
139 0           my $fstore = $self->scan_results_file;
140 0   0       my $args = $self->_aref_to_str($self->arguments, 'args') || '';
141 0 0         my $sudo = ($self->sudo) ? $self->_build_binary('sudo') : '';
142 0           my $cmd = "$sudo $binary $args -p $ports $hosts";
143              
144 0           $self->logger->info('Starting masscan');
145 0           $self->logger->debug("Command: $cmd");
146              
147 0           $self->command_line($cmd);
148 0 0 0       $self->logger->fatal('masscan not found') && croak if (!$binary || $binary !~ m{masscan$}xmi);
      0        
149              
150 0           $self->logger->info('Attempting to run command');
151 0           my $scan = $self->_run_cmd($cmd . " -oJ $fstore");
152              
153 0 0         if ($scan->{success})
154             {
155 0           $self->logger->info('Command executed successfully');
156             }
157             else
158             {
159 0           $self->logger->error("Command has failed: $scan->{stderr} " . 'Ensure root or sudo permissions');
160             }
161              
162 0 0         return ($scan->{success}) ? 1 : 0;
163             }
164              
165             sub scan_results
166             {
167 0     0 1   my $self = shift;
168 0           my $cmd = $self->command_line;
169 0           my $sres = $self->_from_json($self->_slurp_file($self->scan_results_file));
170 0           my %up_hosts;
171              
172 0 0         $self->logger->warn("No results") if (!$sres);
173              
174 0           map{$up_hosts{$_->{ip}} = 1}($sres->@*);
  0            
175 0           $self->logger->info('Collating scan results');
176              
177             return {
178 0           masscan => {
179             command_line => $cmd,
180             scan_stats => {
181             total_hosts => scalar($self->hosts->@*),
182             up_hosts => scalar(%up_hosts),
183             },
184             },
185             scan_results => $sres,
186             }
187             }
188              
189             # internal method _run_cmd
190             # Runs a system command and slurps up the results.
191             #
192             # Returns hashref containing STDOUT, STDERR and if
193             # the command ran successfully.
194             sub _run_cmd
195             {
196 0     0     my $self = shift;
197 0           my $cmd = shift;
198              
199 0           my ($stdin, $stdout, $stderr);
200 0           $stderr = gensym;
201 0           my $pid = open3($stdin, $stdout, $stderr, $cmd);
202 0           waitpid($pid, 0);
203              
204             return {
205 0 0         stdout => $self->_slurp($stdout),
206             stderr => $self->_slurp($stderr),
207             success => (($? >> 8) == 0) ? 1 : 0,
208             }
209             }
210              
211             # internal method _slurp_file
212             # Given a path to a file this will read the entire contents of said file into
213             # memory.
214             #
215             # Returns entire content of file as a Str.
216             sub _slurp_file
217             {
218 0     0     my $self = shift;
219 0           my $path = shift;
220              
221 0           $self->logger->debug("Slurping up file: $path");
222              
223             try
224             {
225 0 0   0     open(my $fh, '<', $path) || die $!;
226 0           my $data = $self->_slurp($fh);
227 0           close($fh);
228              
229 0           return $data;
230             }
231             catch
232             {
233 0     0     $self->logger->warn("$!. " . 'Most likely scan was not successful.');
234 0           return;
235             }
236 0           }
237              
238             # internal method _slurp
239             # Given a glob we'll slurp that all up into memory.
240             #
241             # Returns entire content as a Str.
242             sub _slurp
243             {
244 0     0     my $self = shift;
245 0           my $glob = shift;
246              
247 0           $/ = undef;
248             return (<$glob> || undef)
249 0   0       }
250              
251             # internal method _hosts_to_ips
252             # Ensures sanity of host list & resolves domain names to their IP addresses.
253             #
254             # Returns ArrayRef of valid Ip(s) which will be accepted by masscan.
255             sub _hosts_to_ips
256             {
257 0     0     my $self = shift;
258 0           my $hosts = shift;
259 0           my @sane_hosts;
260              
261 0           for my $host ($hosts->@*)
262             {
263 0           $self->logger->info("Checking $host sanity");
264              
265 0 0         if ($self->_is_valid_host($host))
266             {
267 0 0         if (is_domain($host))
268             {
269 0           my $ips = $self->_resolve_dns($host);
270 0           map{push(@sane_hosts, $_)}($ips->@*);
  0            
271             }
272             else
273             {
274 0           push(@sane_hosts, $host);
275 0           $self->logger->info("Added $host to scan list");
276             }
277             }
278             }
279              
280 0           return \@sane_hosts;
281             }
282              
283             # internal method _is_valid_host
284             # Checks if a provided host is indeed a valid IP || domain.
285             #
286             # Returns True or False.
287             sub _is_valid_host
288             {
289 0     0     my $self = shift;
290 0   0       my $host = shift || 0;
291 0           my $test = $host;
292              
293 0           $test =~ s/\/.*$//g;
294              
295 0 0 0       if (is_ipv4($test) || is_ipv6($test) || is_domain($test))
      0        
296             {
297 0           $self->logger->debug("$host is a valid IP address or domain name");
298 0           return 1;
299             }
300              
301 0           $self->logger->warn("$host is not a valid IP address or domain name");
302 0           return 0;
303             }
304              
305             # internal method _is_valid_port
306             # Checks if a provided is valid in terms of what masscan will accept. This can
307             # look like a single port Int like "80" or a port range like "1-80".
308             #
309             # Returns True or False.
310             sub _is_valid_port
311             {
312 0     0     my $self = shift;
313 0   0       my $port = shift || 0;
314              
315 0 0 0       if ($port =~ m{^\d+$}xm || $port =~ m{^\d+-\d+$}xm)
316             {
317 0           $self->logger->debug("$port is valid port number or port range");
318 0           return 1;
319             }
320              
321 0           $self->logger->warn("$port is not valid port number or port range");
322 0           return 0;
323             }
324              
325             # internal method _aref_to_str
326             # When this module is invoked the hosts and ports to be scanned are provided as
327             # an ArrayRef. This method takes the array and converts it into a valid Str
328             # Which will be accepted by masscan.
329             #
330             # Returns Str
331             sub _aref_to_str
332             {
333 0     0     my $self = shift;
334 0           my $aref = shift;
335 0           my $type = shift;
336 0           my $str;
337              
338 0           $self->logger->info("Converting $type ArrayRef to masscan cli format");
339              
340 0           for ($type)
341             {
342 0 0         m{hosts} && do {map{$str .= ($self->_is_valid_host($_)) ? $_ . ',' : ''}($aref->@*); last};
  0 0          
  0            
  0            
343 0 0         m{ports} && do {map{$str .= ($self->_is_valid_port($_)) ? $_ . ',' : ''}($aref->@*); last};
  0 0          
  0            
  0            
344 0 0         m{args} && do {map{$str .= $_ . ' '}($aref->@*); last};
  0            
  0            
  0            
345             }
346              
347 0           $str =~ s/,$//g;
348 0           $str =~ s/\s+$//g;
349              
350 0           $self->logger->debug("ArrayRef to masscan cli format: $str");
351              
352 0           return $str;
353             }
354              
355             # internal method _from_json
356             # Does what it implies. Will convert JSON format into a Perl data structure.
357             #
358             # Returns Perl data structure.
359             sub _from_json
360             {
361 0     0     my $self = shift;
362 0           my $data = shift;
363              
364             try
365             {
366 0     0     my $json = JSON->new->utf8->space_after->allow_nonref->convert_blessed->relaxed(1);
367 0           $self->logger->info('Converting results from JSON to Perl data structure');
368              
369 0           return $json->decode($data);
370             }
371             catch
372             {
373 0     0     return [];
374             }
375 0           }
376              
377             # internal method _resolve_dns
378             # Given a domain name this method will attempt to resolve the name to it's
379             # IP(s).
380             #
381             # Returns ArrayRef of IP(s).
382             sub _resolve_dns
383             {
384 0     0     my $self = shift;
385 0           my $name = shift;
386 0           my @ips;
387              
388 0           $self->logger->info("Getting IP address for $name");
389              
390             try
391             {
392 0     0     my $resolver = new Net::DNS::Resolver();
393 0           $resolver->retry(3);
394 0           $resolver->tcp_timeout(4);
395 0           $resolver->udp_timeout(4);
396 0           $resolver->nameservers($self->name_servers->@*);
397 0           my $res = $resolver->search($name, 'A');
398              
399 0           for my $answer ($res->answer)
400             {
401 0           for my $ip ($answer->address)
402             {
403 0 0         if ($answer->can('address'))
404             {
405 0           push(@ips, $ip);
406             }
407             }
408             }
409             }
410             catch
411             {
412 0     0     $self->logger->warn("Could not get IP(s) for $name");
413 0           return [];
414 0           };
415              
416 0           return \@ips;
417             }
418              
419             # internal method _build_namer_servers
420             # The default public name servers we'll be using
421             #
422             # Returns ArrayRef of IPs.
423             sub _build_name_servers
424             {
425 0     0     my $self = shift;
426             return [
427 0           '1.1.1.1',
428             '2606:4700:4700::1111',
429             '1.0.0.1',
430             '2606:4700:4700::1001',
431             '8.8.8.8',
432             '2001:4860:4860::8888',
433             '8.8.4.4',
434             '2001:4860:4860::8844'
435             ];
436             }
437              
438             # internal method _tmp_file
439             # Generates a tempoary file where results can be stored.
440             #
441             # Returns full path to temp file.
442             sub _build_scan_results_file
443             {
444 0     0     my $self = shift;
445 0           my $fh = File::Temp->new();
446              
447 0           return $fh->filename;
448             }
449              
450             # internal method _build_binary
451             # If masscan is within the users path then we should be able to find it.
452             #
453             # Returns full path to binary file.
454             sub _build_binary
455             {
456 0     0     my $self = shift;
457 0   0       my $binary = shift || 'masscan';
458              
459 0           local($_);
460              
461 0 0         my $sep = ($^O =~ /Win/) ? ';' : ':';
462              
463 0           for my $dir (split($sep, $ENV{'PATH'}))
464             {
465 0 0         opendir(my $dh, $dir) || next;
466 0           my @files = (readdir($dh));
467 0           closedir($dh);
468              
469 0           my $path;
470              
471 0           for my $file (@files)
472             {
473 0 0         next unless $file =~ m{^$binary(?:.exe)?$};
474 0           $path = File::Spec->catfile($dir, $file);
475 0 0 0       next unless -r $path && (-x _ || -l _);
      0        
476 0           return $path;
477 0           last $dh;
478             }
479             }
480             }
481              
482             # internal method _build_logger
483             # Sets up logging.
484             #
485             # Returns logger Object.
486             sub _build_logger
487             {
488 0     0     my $self = shift;
489 0 0         my $conf = ($self->verbose) ? _build_log_conf('DEBUG') : _build_log_conf('WARN');
490              
491 0           Log::Log4perl->init(\$conf);
492              
493 0           return Log::Log4perl->get_logger(__PACKAGE__);
494             }
495              
496             # internal method _build_log_conf
497             # Our log settings.
498             #
499             # Returns Str with log config.
500             sub _build_log_conf
501             {
502 0     0     my $level = shift;
503              
504             return <<~__LOG_CONF__
505             log4perl.logger = TRACE, Screen
506             log4perl.appender.Screen = Log::Log4perl::Appender::ScreenColoredLevels::UsingMyColors
507             log4perl.appender.Screen.Threshold = $level
508             log4perl.appender.Screen.stderr = 0
509             log4perl.appender.Screen.utf8 = 1
510             log4perl.appender.Screen.layout = Log::Log4perl::Layout::PatternLayout::Multiline
511             log4perl.appender.Screen.color.trace = cyan
512             log4perl.appender.Screen.color.debug = magenta
513             log4perl.appender.Screen.color.info = green
514             log4perl.appender.Screen.color.warn = yellow
515             log4perl.appender.Screen.color.error = red
516             log4perl.appender.Screen.color.fatal = bright_red
517             log4perl.appender.Screen.layout.ConversionPattern = %d{yyyy-MM-dd HH:mm} %M (%L) [%p] %m{indent=4} %n
518             __LOG_CONF__
519 0           }
520              
521             __PACKAGE__->meta->make_immutable;
522              
523             __END__
524              
525             =pod
526              
527             =encoding UTF-8
528              
529             =head1 NAME
530              
531             Masscan::Scanner - A Perl module which helps in using the masscan port scanner.
532              
533             =head1 VERSION
534              
535             version 20200329.150259
536              
537             =head1 SYNOPSIS
538              
539             use Masscan::Scanner;
540              
541             my @hosts = qw(::1 127.0.0.1);
542             my @ports = qw(22 80 443 1-100);
543             my @arguments = qw(--banners);
544              
545             my $mas = Masscan::Scanner->new(hosts => \@hosts, ports => \@ports, arguments => \@arguments);
546              
547             # Add extra hosts or ports
548             $mas->add_host('10.0.0.1');
549             $mas->add_host('10.0.0.0/24');
550             $mas->add_port(25);
551             $mas->add_port(110);
552              
553             # Can add port ranges too
554             $mas->add_port('1024-2048');
555             $mas->add_port('3000-65535');
556              
557             # Can add domains but will incur a performance penalty hence IP(s) and CIDR(s) recommended.
558             # When a domain is added to the list of hosts to be scanned this module will attempt to
559             # resolve all of the A records for the domain name provided and then add the IP(s) to the
560             # scan list.
561             $mas->add_host('averna.id.au');
562             $mas->add_host('duckduckgo.com');
563              
564             # It is usually required that masscan is run as a privilaged user.
565             # Obviously this module can be successfully run as the root user.
566             # However, if this is being run by an unprivilaged user then sudo can be enabled.
567             #
568             # PLEASE NOTE: This module assumes the user can run the masscan command without
569             # providing their password. Usually this is achieved by permitting the user to
570             # run masscan within the /etc/sudoers file like so:a
571             #
572             # In /etc/sudoers: user averna = (root) NOPASSWD: /usr/bin/masscan
573             $mas->sudo(1);
574              
575             # Turn on verbose mode
576             # Default is off
577             $mas->verbose(1);
578              
579             # Add extra masscan arguments
580             $mas->add_argument('--rate 100000');
581              
582             # Set the full path to masscan binary
583             # Default is the module will automatically find the binary full path if it's
584             # withing the users environment path.
585             $mas->binary('/usr/bin/masscan');
586              
587             # Set the name servers to be used for DNS resolution
588             # Default is to use a list of public DNS servers.
589             $mas->name_servers(['192.168.0.100', '192.168.0.101']);
590              
591             # Will initiate the masscan.
592             # If the scan is successful returns True otherwise returns False.
593             my $scan = $mas->scan;
594              
595             # Returns the scan results
596             my $res = $mas->scan_results if ($scan);
597              
598             =head1 METHODS
599              
600             =head2 add_host
601              
602             This method allows the addition of a host to the host list to be scaned.
603              
604             my $mas = Masscan::Scanner->new();
605             $mas->add_host('127.0.0.1');
606              
607             =head2 add_port
608              
609             This method allows the addition of a port or port range to the port list to be scaned.
610              
611             my $mas = Masscan::Scanner->new();
612             $mas->add_port(443);
613             $mas->add_port('1-65535');
614              
615             =head2 add_argument
616              
617             This method allows the addition of masscan command line arguments.
618              
619             my $mas = Masscan::Scanner->new(hosts => ['127.0.0.1', '10.0.0.1'], ports => [80. 443]);
620             $mas->add_argument('--banners');
621             $mas->add_argument('--rate 100000');
622              
623             =head2 scan
624              
625             Will initiate the scan of what hosts & ports have been provided.
626             Returns true fi the scan was successful otherwise returns false.
627              
628             my $mas = Masscan::Scanner->new();
629             $mas->hosts(['127.0.0.1', '::1']);
630             $mas->ports(['22', '80', '443']);
631             $mas->add_port('1024');
632              
633             $mas->scan;
634              
635             =head2 scan_results
636              
637             Returns the result of the masscan as a Perl data structure.
638              
639             my $mas = Masscan::Scanner->new();
640             $mas->hosts(['127.0.0.1', '::1']);
641             $mas->ports(['22', '80', '443']);
642             $mas->add_port('1024');
643              
644             my $scan = $mas->scan;
645              
646             if ($scan)
647             {
648             my $res = $mas->scan_results;
649             }
650              
651             =head1 SCAN RESULTS
652              
653             The scan_results method returns a data structure like so:
654              
655             {
656             'scan_results' => [
657             {
658             'timestamp' => '1584816181',
659             'ip' => '10.0.0.1',
660             'ports' => [
661             {
662             'status' => 'open',
663             'reason' => 'syn-ack',
664             'port' => 443,
665             'proto' => 'tcp',
666             'ttl' => 60
667             }
668             ]
669             },
670             {
671             'timestamp' => '1584816181',
672             'ip' => '10.0.0.2',
673             'ports' => [
674             {
675             'reason' => 'syn-ack',
676             'status' => 'open',
677             'port' => 443,
678             'ttl' => 60,
679             'proto' => 'tcp'
680             }
681             ]
682             },
683             {
684             'ports' => [
685             {
686             'port' => 80,
687             'ttl' => 60,
688             'proto' => 'tcp',
689             'reason' => 'syn-ack',
690             'status' => 'open'
691             }
692             ],
693             'ip' => '10.0.0.1',
694             'timestamp' => '1584816181'
695             },
696             {
697             'ip' => '10.0.0.2',
698             'timestamp' => '1584816181',
699             'ports' => [
700             {
701             'port' => 80,
702             'ttl' => 60,
703             'proto' => 'tcp',
704             'status' => 'open',
705             'reason' => 'syn-ack'
706             }
707             ]
708             },
709             {
710             'timestamp' => '1584816181',
711             'ip' => '10.0.0.3',
712             'ports' => [
713             {
714             'reason' => 'syn-ack',
715             'status' => 'open',
716             'proto' => 'tcp',
717             'ttl' => 111,
718             'port' => 80
719             }
720             ]
721             },
722             {
723             'ports' => [
724             {
725             'ttl' => 111,
726             'proto' => 'tcp',
727             'port' => 443,
728             'reason' => 'syn-ack',
729             'status' => 'open'
730             }
731             ],
732             'timestamp' => '1584816181',
733             'ip' => '10.0.0.3'
734             }
735             ],
736             'masscan' => {
737             'scan_stats' => {
738             'total_hosts' => 4,
739             'up_hosts' => 3
740             },
741             'command_line' => '/usr/bin/masscan --rate 100000 --banners -p 22,80,443,61222,25 10.0.0.2,10.0.0.1,10.0.0.3,10.0.0.4'
742             }
743             };
744              
745             =head1 AUTHOR
746              
747             Sarah Fuller <averna@cpan.org>
748              
749             =head1 COPYRIGHT AND LICENSE
750              
751             This software is copyright (c) 2020 by Sarah Fuller.
752              
753             This is free software; you can redistribute it and/or modify it under
754             the same terms as the Perl 5 programming language system itself.
755              
756             =cut