3 # Fetches the latest ModSecurity Ruleset
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)];
28 my $REQUIRED_SIG_TRUST;
30 eval "use GnuPG qw(:trust)";
32 warn "Could not load GnuPG module - cannot verify ruleset signatures\n";
37 &TRUST_UNDEFINED => "not",
38 &TRUST_NEVER => "not",
39 &TRUST_MARGINAL => "marginally",
40 &TRUST_FULLY => "fully",
41 &TRUST_ULTIMATE => "ultimatly",
43 $REQUIRED_SIG_TRUST = &TRUST_FULLY;
46 ################################################################################
47 ################################################################################
51 getopts('c:r:p:s:v:t:e:f:EuS:D:R:U:F:ldh', \%opt);
53 usage(1) if(defined $opt{h});
54 usage(1) if(@ARGV > 1);
56 # Make sure we have an action
57 if (! grep { defined } @opt{qw(S D R U F l)}) {
58 usage(1, "Action required.");
61 # Merge config with commandline opts
63 %opt = parse_config($opt{c}, \%opt);
66 LWP::Debug::level("+") if ($opt{d});
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+$)|($))/);
72 (my $key = $d) =~ s/^(\D+)\d+$/-$1/;
73 unless (exists $PREFIX_MAP{$key}) {
74 usage(1, "Invalid version (bad suffix \"$d\"): $opt{v}");
76 $opt{v} = qr/^$a\.$b\.$c-$d$/;
79 $opt{v} = qr/^$a\.$b\.$c(?:-|$)/;
82 $opt{v} = qr/^$a\.$b\./;
88 usage(1, "Invalid version: $opt{v}");
91 print STDERR "Using version regex: $opt{v}\n";
98 # Remove trailing slashes from uri and path
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});
106 my $ua = LWP::UserAgent->new(
107 agent => "ModSecurity Updator/$VERSION",
111 requests_redirectable => [qw(GET HEAD)],
112 timeout => ($opt{t} || 600),
116 my $rc = defined($$_[0]) ? $_[0] : 0;
117 my $msg = defined($_[1]) ? "\n$_[1]\n\n" : "";
119 print STDERR << "EOT";
120 ${msg}Usage: $SCRIPT [-c config_file] [[options] [action]
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.
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
142 -c Specify a config file for options.
147 # Get a list of what the repository contains:
148 $SCRIPT -rhttp://host/repo/ -l
150 # Get a partial list of versions 1.5.x:
151 $SCRIPT -rhttp://host/repo/ -v1.5 -l
153 # Get the latest stable version of "breach_ModSecurityCoreRules":
154 $SCRIPT -rhttp://host/repo/ -p/my/repo -Sbreach_ModSecurityCoreRules
156 # Get the latest stable 1.5 release of "breach_ModSecurityCoreRules":
157 $SCRIPT -rhttp://host/repo/ -p/my/repo -v1.5 -Sbreach_ModSecurityCoreRules
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;
172 print STDERR "Parsing config: $file\n" if ($opt{d});
173 open(CFG, "<$file") or die "Failed to open config \"$file\": $!\n";
175 # Skip comments and empty lines
176 next if (/^\s*(?:#|$)/);
180 my($var,$q1,$val,$q2) = (m/^\s*(\S+)\s+(['"]?)(.*)(\2)\s*$/);
184 if ($val =~ m/^(?:true|on)$/i) { $val = 1 };
185 if ($val =~ m/^(?:false|off)$/i) { $val = 0 };
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" }
203 while (($k, $v) = each %{$clo || {}}) {
204 $cfg{$k} = $v if (defined $v);
210 sub repository_dump {
211 my @replist = repository_listing();
213 print STDERR "\nRepository: $opt{r}\n\n";
215 print STDERR "No matching entries.\n";
219 for my $repo (@replist) {
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;
227 print STDERR "Skipping version: $version\n";
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";
239 return grep(/\S/, split(/$CRLFRE/, $res->content)) ;
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";
247 return grep(/\S/, split(/$CRLFRE/, $res->content)) ;
250 sub ruleset_available_versions {
251 return sort sort_versions map { m/_([^_]+)\.zip.*$/; $1 } ruleset_listing($_[0]);
255 my($repo, $version) = @_;
258 if (! -e "$opt{p}" ) {
259 mkdir "$opt{p}" or die "Failed to create \"$opt{p}\": $!\n";
261 if (! -e "$opt{p}/$repo" ) {
262 mkdir "$opt{p}/$repo" or die "Failed to create \"$opt{p}/$repo\": $!\n";
265 my $fn = "${repo}_$version.zip";
266 my $ruleset = "$repo/$fn";
267 my $ruleset_sig = "$repo/$fn.sig";
269 if (-e "$opt{p}/$ruleset") {
270 die "Refused to overwrite ruleset \"$opt{p}/$ruleset\".\n";
274 print STDERR "Fetching: $ruleset ...\n";
277 ":content_file" => "$opt{p}/$ruleset",
279 die "Failed to retrieve ruleset $ruleset: ".$res->status_line()."\n" unless ($res->is_success());
281 # Fetch the ruleset signature
282 if (-e "$opt{p}/$ruleset_sig") {
283 die "Refused to overwrite ruleset signature \"$opt{p}/$ruleset_sig\".\n";
286 "$opt{r}/$ruleset_sig",
287 ":content_file" => "$opt{p}/$ruleset_sig",
290 # Verify the signature if we can
292 die "Failed to retrieve ruleset signature $ruleset_sig: ".$res->status_line()."\n" unless ($res->is_success());
294 ruleset_verifysig("$opt{p}/$ruleset", "$opt{p}/$ruleset_sig");
296 push @fetched, [$repo, $version, $ruleset, undef];
300 my($repo, $version, $ruleset) = @{ $_[0] || [] };
301 my $fn = "$opt{p}/$ruleset";
304 die "Internal Error: No ruleset to unpack - \"$fn\"\n";
308 if (! -e "$opt{s}" ) {
309 mkdir "$opt{s}" or die "Failed to create \"$opt{p}\": $!\n";
311 if (! -e "$opt{s}/$repo" ) {
312 mkdir "$opt{s}/$repo" or die "Failed to create \"$opt{p}/$repo\": $!\n";
314 if (! -e "$opt{s}/$repo/$version" ) {
315 mkdir "$opt{s}/$repo/$version" or die "Failed to create \"$opt{p}/$repo/$version\": $!\n";
318 die "Refused to overwrite previously unpacked \"$opt{s}/$repo/$version\".\n";
324 my $unpackdir = "$opt{s}/$repo/$version";
329 die "Failed to chdir to \"$unpackdir\": $err\n";
332 system(@$UNZIP, $fn);
336 die "Failed to unpack \"$unpackdir\"".($err?": $err":".")."\n";
340 # Add where we unpacked it
341 $_->[3] = $unpackdir;
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/^/;
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+$/;
358 $typere = qr/\d-$type\d+$/;
361 elsif (defined($type)) {
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);
372 print STDERR "Skipping version: $last\n";
376 die "No $type ruleset found.\n";
380 my $version_text = join("\n", map { "$_->[0] v$_->[1]".(defined($_->[3])?": $_->[3]":"") } @_);
381 my $from = $opt{f} ? "From: $opt{f}\n" : "";
383 ModSecurity rulesets updated and ready to install on host $HOST:
387 ModSecurity - http://www.modsecurity.org/
392 open(SM, "|-", @$SENDMAIL) or die "Failed to send mail: $!\n";
393 print STDERR "Sending notification email to: $opt{e}\n";
396 Subject: [$HOST] ModSecurity Ruleset Update Notification
403 sub ruleset_verifysig {
404 my($fn, $sigfn) = @_;
406 print STDERR "Verifying \"$fn\" with signature \"$sigfn\"\n";
407 my $gpg = new GnuPG();
408 my $sig = eval { $gpg->verify( signature => $sigfn, file => $fn ) };
410 print STDERR sig2str($sig)."\n";
412 if (!defined($sig)) {
413 die "Signature validation failed.\n";
415 if ( $sig->{trust} < $REQUIRED_SIG_TRUST ) {
416 die "Signature is not trusted ".$GPG_TRUST{$REQUIRED_SIG_TRUST}.".\n";
423 my %sig = %{ $_[0] || {} };
424 "Signature made ".localtime($sig{timestamp})." by $sig{user} (ID: $sig{keyid}) and is $GPG_TRUST{$sig{trust}} trusted.";
427 ################################################################################
428 ################################################################################
431 if ($opt{l}) { repository_dump(); exit 0 }
433 elsif (defined($opt{S})) { ruleset_fetch_latest($opt{S}, "") }
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") }
439 elsif (defined($opt{U})) { ruleset_fetch_latest($opt{U}, "UNSTABLE") }
441 elsif (defined($opt{F})) { ruleset_fetch_latest($opt{F}, undef) }
445 if (! defined $opt{s} ) { usage(1, "LocalRules is required for unpacking.") }
453 notify_email(@fetched);