Prepravljen Maintainer u debian/control.
[libapache-mod-security.git] / tools / rules-updater.pl.in
1 #!@PERL@
2 #
3 # Fetches the latest ModSecurity Ruleset
4 #
5
6 use strict;
7 use Sys::Hostname;
8 use LWP::UserAgent ();
9 use LWP::Debug qw(-);
10 use URI ();
11 use HTTP::Date ();
12 use Cwd qw(getcwd);
13 use Getopt::Std;
14
15 my $VERSION = "0.0.1";
16 my($SCRIPT) = ($0 =~ m/([^\/\\]+)$/);
17 my $CRLFRE = qr/\015?\012/;
18 my $HOST = Sys::Hostname::hostname();
19 my $UNZIP = [qw(unzip -a)];
20 my $SENDMAIL = [qw(/usr/lib/sendmail -oi -t)];
21 my $HAVE_GNUPG = 0;
22 my %PREFIX_MAP = (
23         -dev => 0,
24         -rc => 1,
25         "" => 9,
26 );
27 my %GPG_TRUST = ();
28 my $REQUIRED_SIG_TRUST;
29
30 eval "use GnuPG qw(:trust)";
31 if ($@) {
32         warn "Could not load GnuPG module - cannot verify ruleset signatures\n";
33 }
34 else {
35         $HAVE_GNUPG = 1;
36         %GPG_TRUST = (
37                 &TRUST_UNDEFINED    => "not",
38                 &TRUST_NEVER        => "not",
39                 &TRUST_MARGINAL     => "marginally",
40                 &TRUST_FULLY        => "fully",
41                 &TRUST_ULTIMATE     => "ultimatly",
42         );
43         $REQUIRED_SIG_TRUST = &TRUST_FULLY;
44 }
45
46 ################################################################################
47 ################################################################################
48
49 my @fetched = ();
50 my %opt = ();
51 getopts('c:r:p:s:v:t:e:f:EuS:D:R:U:F:ldh', \%opt);
52
53 usage(1) if(defined $opt{h});
54 usage(1) if(@ARGV > 1);
55
56 # Make sure we have an action
57 if (! grep { defined } @opt{qw(S D R U F l)}) {
58         usage(1, "Action required.");
59 }
60
61 # Merge config with commandline opts
62 if ($opt{c}) {
63         %opt = parse_config($opt{c}, \%opt);
64 }
65
66 LWP::Debug::level("+") if ($opt{d});
67
68 # Make the version into a regex
69 if (defined $opt{v}) {
70         my($a,$b,$c,$d) = ($opt{v} =~ m/^(\d+)\.?(\d+)?\.?(\d+)?(?:-(\D+\d+$)|($))/);
71         if (defined $d) {
72                 (my $key = $d) =~ s/^(\D+)\d+$/-$1/;
73                 unless (exists $PREFIX_MAP{$key}) {
74                         usage(1, "Invalid version (bad suffix \"$d\"): $opt{v}");
75                 }
76                 $opt{v} = qr/^$a\.$b\.$c-$d$/;
77         }
78         elsif (defined $c) {
79                 $opt{v} = qr/^$a\.$b\.$c(?:-|$)/;
80         }
81         elsif (defined $b) {
82                 $opt{v} = qr/^$a\.$b\./;
83         }
84         elsif (defined $a) {
85                 $opt{v} = qr/^$a\./;
86         }
87         else {
88                 usage(1, "Invalid version: $opt{v}");
89         }
90         if ($opt{d}) {
91                 print STDERR "Using version regex: $opt{v}\n";
92         }
93 }
94 else {
95         $opt{v} = qr/^/;
96 }
97
98 # Remove trailing slashes from uri and path
99 $opt{r} =~ s/\/+$//;
100 $opt{p} =~ s/\/+$//;
101
102 # Required opts
103 usage(1, "Repository (-r) required.") unless(defined $opt{r});
104 usage(1, "Local path (-p) required.") unless(defined $opt{p} or defined $opt{l});
105
106 my $ua = LWP::UserAgent->new(
107         agent => "ModSecurity Updator/$VERSION",
108         keep_alive => 1,
109         env_proxy => 1,
110         max_redirect => 5,
111         requests_redirectable => [qw(GET HEAD)],
112         timeout => ($opt{t} || 600),
113 );
114
115 sub usage {
116         my $rc = defined($$_[0]) ? $_[0] : 0;
117         my $msg = defined($_[1]) ? "\n$_[1]\n\n" : "";
118
119         print STDERR << "EOT";
120 ${msg}Usage: $SCRIPT [-c config_file] [[options] [action]
121
122  Options (commandline will override config file):
123   -r uri   RepositoryURI   Repository URI.
124   -p path  LocalRepository Local repository path to use as base for downloads.
125   -s path  LocalRules      Local rules base path to use for unpacking.
126   -v text  Version         Full/partial version (EX: 1, 1.5, 1.5.2, 1.5.2-dev3)
127   -t secs  Timeout         Timeout for fetching data in seconds (default 600).
128   -e addr  NotifyEmail     Notify via email on update (comma separated list).
129   -f addr  NotifyEmailFrom From address for notification email.
130   -u       Unpack          Unpack into LocalRules/version path.
131   -d       Debug           Print out lots of debugging.
132
133  Actions:
134   -S name    Fetch the latest stable ruleset, "name"
135   -D name    Fetch the latest development ruleset, "name"
136   -R name    Fetch the latest release candidate ruleset, "name"
137   -U name    Fetch the latest unstable (non-stable) ruleset, "name"
138   -F name    Fetch the latest ruleset, "name"
139   -l         Print listing of what is available
140
141  Misc:
142   -c         Specify a config file for options.
143   -h         This help
144
145 Examples:
146
147 # Get a list of what the repository contains:
148 $SCRIPT -rhttp://host/repo/ -l
149
150 # Get a partial list of versions 1.5.x:
151 $SCRIPT -rhttp://host/repo/ -v1.5 -l
152
153 # Get the latest stable version of "breach_ModSecurityCoreRules":
154 $SCRIPT -rhttp://host/repo/ -p/my/repo -Sbreach_ModSecurityCoreRules
155
156 # Get the latest stable 1.5 release of "breach_ModSecurityCoreRules":
157 $SCRIPT -rhttp://host/repo/ -p/my/repo -v1.5 -Sbreach_ModSecurityCoreRules
158 EOT
159         exit $rc;
160 }
161
162 sub sort_versions {
163         (my $A = $a) =~ s/^(\d+)\.(\d+)\.(\d+)(-[^-\d]+|)(\d*)$/sprintf("%03d%03d%03d%03d%03d", $1, $2, $3, $PREFIX_MAP{$4}, $5)/e;
164         (my $B = $b) =~ s/^(\d+)\.(\d+)\.(\d+)(-[^-\d]+|)(\d*)$/sprintf("%03d%03d%03d%03d%03d", $1, $2, $3, $PREFIX_MAP{$4}, $5)/e;
165         return $A cmp $B;
166 }
167
168 sub parse_config {
169         my($file,$clo) = @_;
170         my %cfg = ();
171
172         print STDERR "Parsing config: $file\n" if ($opt{d});
173         open(CFG, "<$file") or die "Failed to open config \"$file\": $!\n";
174         while(<CFG>) {
175                 # Skip comments and empty lines
176                 next if (/^\s*(?:#|$)/);
177
178                 # Parse
179                 chomp;
180                 my($var,$q1,$val,$q2) = (m/^\s*(\S+)\s+(['"]?)(.*)(\2)\s*$/);
181
182                 # Fixup values
183                 $var = lc($var);
184                 if ($val =~ m/^(?:true|on)$/i) { $val = 1 };
185                 if ($val =~ m/^(?:false|off)$/i) { $val = 0 };
186
187                 # Set opts
188                 if    ($var eq "repositoryuri")       { $cfg{r} = $val }
189                 elsif ($var eq "localrepository")        { $cfg{p} = $val }
190                 elsif ($var eq "localrules")       { $cfg{s} = $val }
191                 elsif ($var eq "version")          { $cfg{v} = $val }
192                 elsif ($var eq "timeout")          { $cfg{t} = $val }
193                 elsif ($var eq "notifyemail")      { $cfg{e} = $val }
194                 elsif ($var eq "notifyemailfrom")  { $cfg{f} = $val }
195                 elsif ($var eq "notifyemaildiff")  { $cfg{E} = $val }
196                 elsif ($var eq "unpack")           { $cfg{u} = $val }
197                 elsif ($var eq "debug")            { $cfg{d} = $val }
198                 else { die "Invalid config directive: $var\n" }
199         }
200         close CFG;
201
202         my($k, $v);
203         while (($k, $v) = each %{$clo || {}}) {
204                 $cfg{$k} = $v if (defined $v);
205         }
206
207         return %cfg;
208 }
209
210 sub repository_dump {
211         my @replist = repository_listing();
212
213         print STDERR "\nRepository: $opt{r}\n\n";
214         unless (@replist) {
215                 print STDERR "No matching entries.\n";
216                 return;
217         }
218
219         for my $repo (@replist) {
220                 print "$repo {\n";
221                 my @versions = ruleset_available_versions($repo);
222                 for my $version (@versions) {
223                         if ($version =~ m/$opt{v}/) {
224                                 printf "%15s: %s_%s.zip\n", $version, $repo, $version;
225                         }
226                         elsif ($opt{d}) {
227                                 print STDERR "Skipping version: $version\n";
228                         }
229                 }
230                 print "}\n";
231         }
232 }
233
234 sub repository_listing {
235         my $res = $ua->get("$opt{r}/.listing");
236         unless ($res->is_success()) {
237                 die "Failed to get repository listing \"$opt{r}/.listing\": ".$res->status_line()."\n";
238         }
239         return grep(/\S/, split(/$CRLFRE/, $res->content)) ;
240 }
241
242 sub ruleset_listing {
243         my $res = $ua->get("$opt{r}/$_[0]/.listing");
244         unless ($res->is_success()) {
245                 die "Failed to get ruleset listing \"$opt{r}/$_[0]/.listing\": ".$res->status_line()."\n";
246         }
247         return grep(/\S/, split(/$CRLFRE/, $res->content)) ;
248 }
249
250 sub ruleset_available_versions {
251         return sort sort_versions map { m/_([^_]+)\.zip.*$/; $1 } ruleset_listing($_[0]);
252 }
253
254 sub ruleset_fetch {
255         my($repo, $version) = @_;
256
257         # Create paths
258         if (! -e "$opt{p}" ) {
259                 mkdir "$opt{p}" or die "Failed to create \"$opt{p}\": $!\n";
260         }
261         if (! -e "$opt{p}/$repo" ) {
262                 mkdir "$opt{p}/$repo" or die "Failed to create \"$opt{p}/$repo\": $!\n";
263         }
264
265         my $fn = "${repo}_$version.zip";
266         my $ruleset = "$repo/$fn";
267         my $ruleset_sig = "$repo/$fn.sig";
268
269         if (-e "$opt{p}/$ruleset") {
270                 die "Refused to overwrite ruleset \"$opt{p}/$ruleset\".\n";
271         }
272
273         # Fetch the ruleset
274         print STDERR "Fetching: $ruleset ...\n";
275         my $res = $ua->get(
276                 "$opt{r}/$ruleset",
277                 ":content_file" => "$opt{p}/$ruleset",
278         );
279         die "Failed to retrieve ruleset $ruleset: ".$res->status_line()."\n" unless ($res->is_success());
280
281         # Fetch the ruleset signature
282         if (-e "$opt{p}/$ruleset_sig") {
283                 die "Refused to overwrite ruleset signature \"$opt{p}/$ruleset_sig\".\n";
284         }
285         $res = $ua->get(
286                 "$opt{r}/$ruleset_sig",
287                 ":content_file" => "$opt{p}/$ruleset_sig",
288         );
289
290         # Verify the signature if we can
291         if ($HAVE_GNUPG) {
292                 die "Failed to retrieve ruleset signature $ruleset_sig: ".$res->status_line()."\n" unless ($res->is_success());
293
294                 ruleset_verifysig("$opt{p}/$ruleset", "$opt{p}/$ruleset_sig");
295         }
296         push @fetched, [$repo, $version, $ruleset, undef];
297 }
298
299 sub ruleset_unpack {
300         my($repo, $version, $ruleset) = @{ $_[0] || [] };
301         my $fn = "$opt{p}/$ruleset";
302
303         if (! -e "$fn" ) {
304                 die "Internal Error: No ruleset to unpack - \"$fn\"\n";
305         }
306
307         # Create paths
308         if (! -e "$opt{s}" ) {
309                 mkdir "$opt{s}" or die "Failed to create \"$opt{p}\": $!\n";
310         }
311         if (! -e "$opt{s}/$repo" ) {
312                 mkdir "$opt{s}/$repo" or die "Failed to create \"$opt{p}/$repo\": $!\n";
313         }
314         if (! -e "$opt{s}/$repo/$version" ) {
315                 mkdir "$opt{s}/$repo/$version" or die "Failed to create \"$opt{p}/$repo/$version\": $!\n";
316         }
317         else {
318                 die "Refused to overwrite previously unpacked \"$opt{s}/$repo/$version\".\n";
319         }
320
321         # TODO: Verify sig
322
323         my $pwd = getcwd();
324         my $unpackdir = "$opt{s}/$repo/$version";
325         chdir "$unpackdir";
326         if ($@) {
327                 my $err = $!;
328                 chdir $pwd;
329                 die "Failed to chdir to \"$unpackdir\": $err\n";
330         }
331         undef $!;
332         system(@$UNZIP, $fn);
333         if ($? != 0) {
334                 my $err = $!;
335                 chdir $pwd;
336                 die "Failed to unpack \"$unpackdir\"".($err?": $err":".")."\n";
337         }
338         chdir $pwd;
339
340         # Add where we unpacked it
341         $_->[3] = $unpackdir;
342
343         return 0;
344 }
345
346 sub ruleset_fetch_latest {
347         my($repo, $type) = @_;
348         my @versions = ruleset_available_versions($repo);
349         my $verre = defined($opt{v}) ? qr/^$opt{v}/ : qr/^/;
350         my $typere = undef;
351         
352         # Figure out what to look for
353         if (defined($type) and $type ne "") {
354                 if ($type eq "UNSTABLE") {
355                         $typere = qr/\d-\D+\d+$/;
356                 }
357                 else {
358                         $typere = qr/\d-$type\d+$/;
359                 }
360         }
361         elsif (defined($type)) {
362                 qr/\.\d+$/;
363         }
364
365         while (@versions) {
366                 my $last = pop(@versions);
367                 # Check REs on version
368                 if ($last =~ m/$opt{v}/ and (!defined($typere) || $last =~ m/$typere/)) {
369                         return ruleset_fetch($repo, $last);
370                 }
371                 if ($opt{d}) {
372                         print STDERR "Skipping version: $last\n";
373                 }
374         }
375
376         die "No $type ruleset found.\n";
377 }
378
379 sub notify_email {
380         my $version_text = join("\n", map { "$_->[0] v$_->[1]".(defined($_->[3])?": $_->[3]":"") } @_);
381         my $from = $opt{f} ? "From: $opt{f}\n" : "";
382         my $body = << "EOT";
383 ModSecurity rulesets updated and ready to install on host $HOST:
384
385 $version_text
386
387 ModSecurity - http://www.modsecurity.org/
388 EOT
389
390         # TODO: Diffs
391
392         open(SM, "|-", @$SENDMAIL) or die "Failed to send mail: $!\n";
393         print STDERR "Sending notification email to: $opt{e}\n";
394         print SM << "EOT";
395 ${from}To: $opt{e}
396 Subject: [$HOST] ModSecurity Ruleset Update Notification
397
398 $body
399 EOT
400         close SM;
401 }
402
403 sub ruleset_verifysig {
404         my($fn, $sigfn) = @_;
405
406         print STDERR "Verifying \"$fn\" with signature \"$sigfn\"\n";
407         my $gpg = new GnuPG();
408         my $sig = eval { $gpg->verify( signature => $sigfn, file => $fn ) };
409         if (defined $sig) {
410                 print STDERR sig2str($sig)."\n"; 
411         }
412         if (!defined($sig)) {
413                 die "Signature validation failed.\n";
414         }
415         if ( $sig->{trust} < $REQUIRED_SIG_TRUST ) {
416                 die "Signature is not trusted ".$GPG_TRUST{$REQUIRED_SIG_TRUST}.".\n";
417         }
418
419         return;
420 }
421
422 sub sig2str {
423         my %sig = %{ $_[0] || {} };
424         "Signature made ".localtime($sig{timestamp})." by $sig{user} (ID: $sig{keyid}) and is $GPG_TRUST{$sig{trust}} trusted.";
425 }
426
427 ################################################################################
428 ################################################################################
429
430 # List what is there
431 if ($opt{l}) { repository_dump(); exit 0 }
432 # Latest stable
433 elsif (defined($opt{S})) { ruleset_fetch_latest($opt{S}, "") }
434 # Latest development
435 elsif (defined($opt{D})) { ruleset_fetch_latest($opt{D}, "dev") }
436 # Latest release candidate
437 elsif (defined($opt{R})) { ruleset_fetch_latest($opt{R}, "rc") }
438 # Latest unstable
439 elsif (defined($opt{U})) { ruleset_fetch_latest($opt{U}, "UNSTABLE") }
440 # Latest (any type)
441 elsif (defined($opt{F})) { ruleset_fetch_latest($opt{F}, undef) }
442
443 # Unpack
444 if ($opt{u}) {
445         if (! defined $opt{s} ) { usage(1, "LocalRules is required for unpacking.") }
446         for (@fetched) {
447                 ruleset_unpack($_);
448         }
449 }
450
451 # Unpack
452 if ($opt{e}) {
453         notify_email(@fetched);
454 }