File Coverage

blib/lib/Masscan/Scanner.pm
Criterion Covered Total %
statement 63 218 28.9
branch 0 46 0.0
condition 0 31 0.0
subroutine 21 44 47.7
pod 5 5 100.0
total 89 344 25.8


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