File Coverage

blib/lib/Masscan/Scanner.pm
Criterion Covered Total %
statement 63 222 28.3
branch 0 48 0.0
condition 0 31 0.0
subroutine 21 46 45.6
pod 5 5 100.0
total 89 352 25.2


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