File Coverage

blib/lib/Bison.pm
Criterion Covered Total %
statement 54 263 20.5
branch 10 108 9.2
condition 0 21 0.0
subroutine 10 28 35.7
pod 18 22 81.8
total 92 442 20.8


line stmt bran cond sub pod time code
1             package Bison;
2              
3             =head1 NAME
4              
5             Bison - IPTables Script generator
6              
7             =head1 DESCRIPTION
8              
9             Bison can be used to generate a firewall script for your Linux box. It doesn't run the commands for you
10             but generates the needed commands for you to run based on the methods you pass. It's also a lot of
11             fun to build them.
12              
13             =head1 SYNOPSIS
14              
15             The synopsis is basic. All the methods have been exported. So a simple firewall script would be:
16              
17             use Bison;
18            
19             override_global({ip_address => '10.1.1.5'});
20            
21             # drop everything by default
22             default_policy({
23             INPUT => 'DROP',
24             FORWARD => 'DROP'
25             OUTPUT => 'ACCEPT',
26             });
27            
28             # filter bad tcp packets into a special chain
29             drop_bad_tcp_flags();
30              
31             # create a custom chain and set default behaviour to drop
32             chain ('new', {
33             name => 'my_firewall',
34             jump => 'DROP',
35             });
36              
37             # setup logging for the new chain
38             log_setup ('my_firewall', { time => 7, duration => 'minute', prefix => 'My Cool Firewall' });
39            
40             bison_finish();
41              
42             Obviously the above script would lock you out of your system. But it shows it's a lot easier to write a bit
43             of Perl than remember long-winded IPTables commands.
44              
45             =cut
46              
47 1     1   23270 use warnings;
  1         3  
  1         39  
48 1     1   6 use strict;
  1         2  
  1         38  
49 1     1   26 use 5.010;
  1         9  
  1         64  
50              
51             $Bison::VERSION = '0.05';
52              
53 1     1   6 use vars qw/$bopts/;
  1         2  
  1         123  
54              
55             our $bopts = {
56             ipt => '/sbin/iptables',
57             dry => 0,
58             iface => 'eth0',
59             ip_is => 'dynamic',
60             errors => [],
61             buffer => [],
62             chains => [],
63             firewall => 'bison',
64             };
65              
66             initfw(); # call initfw to setup main chains
67              
68 1     1   6 use base 'Exporter';
  1         2  
  1         2049  
69             our @EXPORT = qw/
70             initfw
71             flush
72             override_global
73             getvars
74             default_policy
75             bison_finish
76             source_nat
77             preroute
78             chain
79             log_setup
80             accept_local
81             accept_all_from
82             drop_bad_tcp_flags
83             drop_icmp
84             open_service
85             enable_ip_forwarding
86             drop_netbios
87             enable_state_matching
88             forward
89             /;
90              
91             =head2 initfw
92              
93             This function should be called before anything else.
94             It sets up the default firewall chain and a catchall filter.
95              
96             =cut
97              
98             sub initfw {
99 1     1 1 4 my $args = shift;
100             # create main bison chain
101 1         12 chain('new', { name => $bopts->{firewall}, jump => 'drop' });
102 1         7 log_setup($bopts->{firewall});
103              
104             # now the catchall filter, known as dropwall
105 1         7 chain('new', { name => 'dropwall', jump => 'drop'});
106 1         5 log_setup('dropwall', { prefix => 'Bison DropWall'});
107              
108             # silent logging chain for all those annoying things
109 1         4 chain('new', { name => 'silent', jump => 'drop'});
110             }
111              
112             sub has_ip_address {
113 0 0   0 0 0 if (! defined $bopts->{ip_address}) {
114 0         0 die "Can't continue. No IP Address set. Please set one with override_globals({ip_address => '0.0.0.0'})\n";
115             }
116             }
117              
118             =head2 forward
119              
120             Handles all forwarding related stuff. ie: Forward packets from an internal network (eth1) to the internet (eth0).
121              
122             # generate something like iptables -A FORWARD -i eth0 -o eth1 -m state --state ESTABLISHED,RELATED -j ACCEPT
123             forward({
124             from => 'eth1',
125             to => 'eth0',
126             type => 'related'
127             });
128              
129             # .. or simply just forward the packets from eth1 to eth0
130             forward({ from => 'eth1', to => 'eth0' });
131              
132             =cut
133              
134             sub forward {
135 0     0 1 0 my $args = shift;
136              
137 0         0 my ($from, $to, $type);
138 0         0 for(keys %$args) {
139 0 0       0 $from = $args->{$_} if $_ eq 'from';
140 0 0       0 $to = $args->{$_} if $_ eq 'to';
141 0 0       0 $type = $args->{$_} if $_ eq 'type';
142             }
143              
144 0 0 0     0 if (! $from || ! $to) {
145 0         0 log_error('forward(): From and To need to be set to forward packets');
146 0         0 return 0;
147             }
148              
149 0         0 ipt("-A FORWARD -i $from -o $to -j ACCEPT");
150 0 0       0 if ($type) {
151 0 0 0     0 if ($type eq 'related' || $type eq 'established') {
152 0         0 ipt("-A FORWARD -i $to -o $from -m state --state ESTABLISHED,RELATED -j ACCEPT");
153 0         0 ipt("-A INPUT -i $to -m state --state ESTABLISHED,RELATED -j ACCEPT");
154             }
155             }
156             }
157              
158             =head2 drop_bad_tcp_flags
159              
160             Catches any malicious TCP packets into a badflags chain, then prefixes the log as that chain.
161             Should help prevent force fragment and XMAS packets. Also checks to make sure new TCP connections
162             are SYN packets.
163             This section could do with a bit more work, but this is still a beta release :)
164              
165             =cut
166              
167             sub drop_bad_tcp_flags {
168 0     0 1 0 my ($chain, $prefix) = @_;
169              
170 0   0     0 $chain = $chain||'badflags';
171 0   0     0 $prefix = $prefix||'Bison BadFlags';
172 0         0 ($bopts->{badflags}, $bopts->{badflags_prefix}) = ($chain, $prefix);
173             # create a chain to handle them
174 0         0 chain('new', { name => $chain, jump => 'drop' });
175              
176             # add alert options with defaults
177 0         0 log_setup($chain, { prefix => $prefix});
178              
179 0         0 ipt("-A INPUT -p tcp ! --syn -m state --state NEW -j $chain");
180 0         0 ipt("-A INPUT -f -j $chain");
181 0         0 ipt("-A INPUT -p tcp --tcp-flags ALL FIN,URG,PSH -j $chain");
182 0         0 ipt("-A INPUT -p tcp --tcp-flags ALL ALL -j $chain");
183 0         0 ipt("-A INPUT -p tcp --tcp-flags ALL SYN,RST,ACK,FIN,URG -j $chain");
184 0         0 ipt("-A INPUT -p tcp --tcp-flags ALL NONE -j $chain");
185 0         0 ipt("-A INPUT -p tcp --tcp-flags SYN,RST SYN,RST -j $chain");
186 0         0 ipt("-A INPUT -p tcp --tcp-flags SYN,FIN SYN,FIN -j $chain");
187 0         0 return 1;
188             }
189              
190             =head2 open_service
191              
192             Open ports to a service by name (www, ssh, ftp). If no arguments are passed
193             it will open access to everyone. If you pass a hash with to => then the port
194             will be only available to that ip address.
195              
196             open_service('ssh', { to => '10.1.1.5' }); # open 22 to 10.1.1.5 only
197             open_service('www'); # open port 80 to all
198              
199             =cut
200              
201             sub open_service {
202 0     0 1 0 my ($service, $args) = @_;
203              
204 0         0 my @services = qw/ssh www ftp/;
205 0 0       0 if (! grep { $_ eq $service } @services) {
  0         0  
206 0         0 log_error("open_service: No such service $service");
207 0         0 return 0;
208             }
209              
210 0         0 my ($to, $port);
211 0         0 for(keys %$args) {
212 0 0       0 $to = $args->{$_} if $_ eq 'to';
213             }
214              
215 0         0 given(lc $service) {
216 0         0 when ('ssh') { $port = 22; }
  0         0  
217 0         0 when ('www') { $port = 80; }
  0         0  
218 0         0 when ('ftp') { $port = '20:21'; }
  0         0  
219             }
220            
221 0 0       0 if ($to) { ipt("-A INPUT -i $bopts->{iface} -s $to -d 0/0 -p tcp --dport $port -j ACCEPT"); }
  0         0  
222 0         0 else { ipt("-A INPUT -i $bopts->{iface} -s 0/0 -d 0/0 -p tcp --dport $port -j ACCEPT"); }
223              
224 0         0 return 1;
225             }
226              
227             =head2 drop_icmp
228              
229             Drops all ICMP requests, but opens a few by default.
230             If you pass an array it will only allow what is requested
231              
232             drop_icmp( [qw/0 8 11/] );
233              
234             =cut
235              
236             sub drop_icmp {
237 0     0 1 0 my $args = shift;
238              
239             # drop all icmp requests, except a few
240             # 0 - Echo Reply
241             # 3 - Destination Unreachable
242             # 11 - Time Exceeded
243             # 8 - Echo
244 0 0       0 if ($args) {
245 0 0       0 if (ref $args eq 'ARRAY') {
246 0         0 for (@$args) {
247 0 0       0 if ($_ == 8) { ipt("-A INPUT -p icmp --icmp-type 8 -m limit --limit 1/second -j ACCEPT"); next; }
  0         0  
  0         0  
248 0         0 ipt("-A INPUT -p icmp --icmp-type $_ -j ACCEPT");
249             }
250             }
251             }
252             else {
253 0         0 ipt("-A INPUT -p icmp --icmp-type 0 -j ACCEPT");
254 0         0 ipt("-A INPUT -p icmp --icmp-type 3 -j ACCEPT");
255 0         0 ipt("-A INPUT -p icmp --icmp-type 11 -j ACCEPT");
256 0         0 ipt("-A INPUT -p icmp --icmp-type 8 -m limit --limit 1/second -j ACCEPT");
257 0         0 ipt("-A INPUT -p icmp -j $bopts->{firewall}");
258             }
259 0         0 return 1;
260             }
261              
262             =head2 chain_list
263             =cut
264              
265             =head2 chain
266              
267             Perform chain events.
268              
269             chain('new', { name => 'my_new_chain', jump => 'drop' });
270             chain('list') # returns an array of chains you have created
271              
272             =cut
273              
274             sub chain {
275 3     3 1 8 my ($do, $args) = @_;
276              
277 3 50       10 if ($do eq 'new') {
    0          
278 3 50       12 if (! defined $args->{name}) {
279 0         0 log_error('chan(): No name supplied');
280 0         0 return 0;
281             }
282            
283 3         7 my $chain = $args->{name};
284 3         34 ipt("-N $chain");
285 3 50       8 if ($args) {
286 3         11 for (keys %$args) {
287 6 100       18 if ($_ eq 'jump') {
288 3         9 my $jump = uc $args->{$_};
289 3         9 ipt("-A $chain -j $jump");
290             }
291             }
292             }
293 3         15 push(@{$bopts->{chains}}, $chain);
  3         8  
294             }
295             elsif ($do eq 'list') {
296 0         0 return @{$bopts->{chains}};
  0         0  
297             }
298             else {
299 0         0 log_error("chain(): No such option '$do'");
300 0         0 return 0;
301             }
302              
303 3         5 return 1;
304             }
305              
306             =head2 drop_netbios
307              
308             We don't necessarily want netbios packets, so here's the
309             option to disable them.
310             You can choose to log them silently, or loudly to the main
311             firewall chain.
312              
313             drop_netbios(); # drop netbios silently
314             drop_netbios(1); # drop packets loudly by logging to firewall
315              
316             =cut
317              
318             sub drop_netbios {
319 0     0 1 0 my $loud = shift;
320              
321 0         0 my $chain = 'silent';
322 0 0       0 if ($loud) { $chain = $bopts->{firewall}; }
  0         0  
323            
324 0         0 ipt("-A INPUT -p udp --sport 137 --dport 137 -j $chain");
325 0         0 return 1;
326             }
327              
328             =head2 log_setup
329              
330             Sets up logging for a chain. You can specify the time, duration and prefix.
331              
332             log_setup ('mychain', { time => 8, duration => 'minute', prefix => 'MyChain Log'});
333             # 8 alerts per minute
334              
335             =cut
336              
337             sub log_setup {
338 2     2 1 5 my ($chain, $args) = @_;
339              
340 2 50       6 $chain = 'INPUT' if ! defined $chain;
341 2         4 my $prefix = 'Bison'; # default log prefix :D
342 2         3 my $time = 15;
343 2         2 my $duration = 'minute';
344              
345 2         8 for(keys %$args) {
346 1 50       4 $prefix = $args->{$_} if $_ eq 'prefix';
347 1 50       3 $time = $args->{$_} if $_ eq 'time';
348 1 50       6 $duration = $args->{$_} if $_ eq 'duration';
349             }
350              
351 2         13 ipt("-A $chain -m limit --limit $time/$duration -j LOG --log-prefix [$prefix]");
352 2         4 return 1;
353             }
354              
355             =head2 source_nat
356              
357             Sources everything going out the interface to be the given IP address.
358              
359             source_nat({ as => '10.1.1.5'});
360              
361             =cut
362              
363             sub source_nat {
364 0     0 1 0 my (%args) = @_;
365              
366 0         0 has_ip_address();
367              
368 0         0 my $ip_is = $bopts->{ip_is};
369 0 0       0 if (%args) { $ip_is = $args{as}; }
  0         0  
370              
371 0 0       0 if ($ip_is eq 'static') { ipt("-t nat -A POSTROUTING -o $bopts->{iface} -j SNAT --to $bopts->{ip_address}"); }
  0 0       0  
372 0         0 elsif ($ip_is eq 'dynamic') { ipt("-t nat -A POSTROUTING -o $bopts->{iface} -j MASQUERADE"); }
373             else {
374 0         0 log_error("Unknown IP Address type in source_nat: $ip_is");
375 0         0 return 0;
376             }
377              
378 0         0 return 1;
379             }
380            
381              
382             sub getvars {
383 1     1   6224 use Data::Dumper;
  1         18281  
  1         1921  
384 0     0 0 0 say Dumper($bopts);
385             }
386              
387             =head2 override_global
388              
389             Overrides any default settings, and allows you to create new ones.
390              
391             override_global({ iface => eth0, ip_address => '10.1.1.6'});
392              
393             =cut
394              
395             sub override_global {
396 0     0 1 0 my $opt = shift;
397 0         0 for (keys %$opt) {
398 0         0 say "-> Global override: $_ => $opt->{$_}";
399 0         0 $bopts->{$_} = $opt->{$_};
400             }
401             }
402              
403             =head2 preroute
404              
405             Preroute options. ie: route an incoming port to a specified IP in the nat
406              
407             preroute('ports', { ports => '22:25', proto => 'tcp', to => '10.1.1.9' });
408              
409             =cut
410              
411             sub preroute {
412 0     0 1 0 my ($what, $args) = @_;
413              
414 0 0       0 if ($what eq 'ports') {
415 0         0 my ($proto, $ports, $to);
416 0         0 for (keys %$args) {
417 0 0       0 $to = $args->{$_} if $_ eq 'to';
418 0 0       0 $ports = $args->{$_} if $_ eq 'ports';
419 0 0       0 $proto = $args->{$_} if $_ eq 'proto';
420             }
421              
422 0 0 0     0 if ((! $to || ! $ports)) {
423 0         0 log_error("Prerouting ports needs to and ports attributes");
424 0         0 return;
425             }
426            
427 0 0 0     0 if ((! defined $proto || $proto eq 'all')) {
428 0         0 ipt("-t nat -A PREROUTING -i $bopts->{iface} -p tcp --dport $ports -j DNAT --to $to");
429 0         0 ipt("-t nat -A PREROUTING -i $bopts->{iface} -p udp --dport $ports -j DNAT --to $to");
430             }
431 0         0 else { ipt("-t nat -A PREROUTING -i $bopts->{iface} -p $proto --dport $ports -j DNAT --to $to"); }
432 0         0 return 1;
433             }
434             }
435              
436             =head2 enable_state_matching
437              
438             Accept related and established connections so client side
439             activities, ie: ftp, work correctly.
440              
441             =cut
442              
443             sub enable_state_matching {
444 0     0 1 0 ipt("-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT");
445 0         0 return 1;
446             }
447              
448             =head2 enable_ip_forwarding
449              
450             Simply switches on IP forwarding in /proc/sys/net/ipv4/ip_forward, if
451             your system supports it.
452              
453             =cut
454              
455             sub enable_ip_forwarding {
456 0 0   0 1 0 if ($bopts->{dry}) { say "-> NAT enabled"; }
  0         0  
457 0         0 else { system('echo 1 > /proc/sys/net/ipv4/ip_forward'); }
458             }
459              
460             =head2 accept_local
461              
462             Accept everything locally
463              
464             =cut
465              
466             sub accept_local {
467 0     0 1 0 ipt('-A INPUT -i lo -j ACCEPT');
468 0         0 return 1;
469             }
470              
471             =head2 accept_all_from
472              
473             Accept all incoming connections from a specific IP, or locally.
474             You can pass an array to allow multiple sources.
475              
476             accept_all_from('local');
477             accept_all_from('10.1.1.5');
478             accept_all_from([qw/10.1.1.4 10.1.1.5 10.1.2.7/]);
479              
480             =cut
481              
482             sub accept_all_from {
483 0     0 1 0 my $args = shift;
484              
485 0 0       0 if (ref $args eq 'ARRAY') {
    0          
486 0         0 for (@$args) {
487 0         0 ipt("-A INPUT -s $_ -d 0/0 -p all -j ACCEPT");
488             }
489             }
490 0         0 elsif ($args eq 'local') { ipt('-A INPUT -i lo -j ACCEPT'); }
491 0         0 else { ipt("-A INPUT -s $args -d 0/0 -p all -j ACCEPT"); }
492              
493 0         0 return 1;
494             }
495              
496             =head2 flush
497              
498             Flushes specific chains, including nat and mangle.
499              
500             flush(); # flush everything
501             flush([qw/INPUT FORWARD nat/])
502              
503             =cut
504              
505             sub flush {
506 0     0 1 0 my $opts = shift;
507 0         0 my $errors = 0;
508 0         0 my @flush_items;
509 0 0       0 if (ref $opts eq 'ARRAY') {
    0          
    0          
510 0         0 for (@{$opts}) {
  0         0  
511 0         0 given (uc $_) {
512 0         0 when ('INPUT') { push @flush_items, $_; }
  0         0  
513 0         0 when ('OUTPUT') { push @flush_items, $_; }
  0         0  
514 0         0 when ('FORWARD') { push @flush_items, $_; }
  0         0  
515 0         0 when ('MANGLE') { push @flush_items, $_; }
  0         0  
516 0         0 when ('NAT') { push @flush_items, $_; }
  0         0  
517 0         0 when ('CUSTOM') { push @flush_items, $_; }
  0         0  
518             }
519             }
520             }
521             elsif (ref $opts eq 'SCALAR') {
522 0 0       0 if (! grep $_ eq $opts, [qw/INPUT OUTPUT FORWARD mangle nat custom/]) {
523 0         0 die "Can't flush chain '$opts'. Not a valid chain";
524             }
525 0         0 push @flush_items, $opts;
526             }
527 0         0 elsif (! defined $opts) { @flush_items = qw/INPUT OUTPUT FORWARD mangle nat custom/; }
528              
529 0         0 my $item;
530 0         0 for (@flush_items) {
531 0 0 0     0 next if $_ eq ''||undef;
532 0         0 $item = lc $_;
533 0 0 0     0 if (($item eq 'nat' || $item eq 'mangle')) {
    0          
534 0 0       0 if (ipt("-F -t $item")) {
535 0         0 say "-> Flushing $item";
536             }
537 0         0 else { log_error("Could not flush $item"); $errors++; }
  0         0  
538             }
539             elsif ($item eq 'custom') {
540 0 0       0 if (ipt("-X")) {
541 0         0 say "-> Flushing custom chains (-X)";
542             }
543 0         0 else { log_error("Could not flush custom chains"); $errors++; }
  0         0  
544             }
545             else {
546 0         0 $item = uc $item;
547 0 0       0 if (ipt("-F $item")) {
548 0         0 say "-> Flushing chain $item";
549             }
550 0         0 else { log_error("Could not flush chain $item"); $errors++; }
  0         0  
551             }
552             }
553 0 0       0 return 1 if ! $errors;
554             }
555              
556             sub ipt {
557 8     8 0 12 my $cmd = shift;
558 8         13 my $ipt = $bopts->{ipt};
559            
560 8 50       19 if ($bopts->{debug}) {
561 0         0 say "[debug] $bopts->{ipt} $cmd";
562             }
563              
564             #my $out = `$ipt $cmd 2>&1`;
565             #if ($out ne '') {
566             # return 0;
567             #}
568             #else { return 1; }
569 8         9 push @{$bopts->{buffer}}, $cmd;
  8         15  
570 8         18 return 1;
571             }
572              
573             sub log_error {
574 0     0 0   my $err = shift;
575              
576 0           push (@{$bopts->{errors}}, $err);
  0            
577             }
578              
579             =head2 default_policy
580              
581             Sets the default policy for the specified chain.
582              
583             default_policy({
584             INPUT => 'DROP',
585             FORWARD => 'DROP',
586             });
587              
588             =cut
589              
590             sub default_policy {
591 0     0 1   my $opt = shift;
592              
593 0           my $policy;
594 0           my @chains = qw/INPUT OUTPUT FORWARD/;
595 0           for (keys %$opt) {
596 0           $policy = uc $opt->{$_};
597 0 0         if (! grep $_ eq $_, @chains) {
598 0           log_error("No such chain: $_");
599             }
600             else {
601 0 0         if (ipt("-P $_ $policy")) {
602 0           say "-> Setting default policy for $_ to $policy";
603 0           return 1;
604             }
605             else {
606 0           log_error("Could not set default policy for $_ to $policy");
607             }
608             }
609             }
610             }
611              
612             =head2 bison_finish
613              
614             Call this method last, and don't forget. It cleans everything up
615             and checks for errors. Also, it can print out a list of the IPTables
616             commands you need to generate your firewall script
617              
618             =cut
619              
620             sub bison_finish {
621 0     0 1   my $file = shift;
622              
623 0           my $errors = 0;
624 0 0         if (@{$bopts->{errors}} > 0) { $errors = @{$bopts->{errors}} }
  0            
  0            
  0            
625              
626 0 0         if ($errors > 0) {
627 0           say "Errors";
628 0           my $i;
629 0           say "---";
630 0           for (@{$bopts->{errors}}) {
  0            
631 0           $i++;
632 0           say "$i: $_";
633             }
634             }
635 0           else { say "No problems occurred"; }
636              
637             # read buffer
638 0 0         unless (! $bopts->{verbose}) {
639 0           my $i = 0;
640 0           for (@{$bopts->{buffer}}) {
  0            
641 0           $i++;
642 0           say "$i: $_";
643             }
644             }
645              
646             # do we have a file to write to?
647 0 0         if ($file) {
648 0 0         open(my $fh, ">$file") or die "Could not write to $file\n";
649 0           print $fh "#########################\n";
650 0           print $fh "# Generated by Bison $Bison::VERSION\n";
651 0           print $fh "#########################\n";
652 0           for (@{$bopts->{buffer}}) {
  0            
653 0           print $fh $_ . "\n";
654             }
655 0           close $fh;
656             }
657             }
658              
659             =head1 BUGS
660              
661             Please e-mail brad@geeksware.net
662              
663             =head1 AUTHOR
664              
665             Brad Haywood
666              
667             =head1 COPYRIGHT & LICENSE
668              
669             Copyright 2011 the above author(s).
670              
671             This sofware is free software, and is licensed under the same terms as perl itself.
672              
673             =cut
674              
675             1; # End of Bison