3 # Run regression tests.
5 # Syntax: run-regression-tests.pl [options] [file [N]]
7 # All: run-regression-tests.pl
8 # All in file: run-regression-tests.pl file
9 # Nth in file: run-regression-tests.pl file N
12 use Time::HiRes qw(gettimeofday sleep);
13 use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG);
14 use File::Spec qw(rel2abs);
15 use File::Basename qw(basename dirname);
17 use IPC::Open2 qw(open2);
18 use IPC::Open3 qw(open3);
24 my @TYPES = qw(config misc action target rule);
25 my $SCRIPT = basename($0);
26 my $SCRIPT_DIR = File::Spec->rel2abs(dirname($0));
27 my $REG_DIR = "$SCRIPT_DIR/regression";
28 my $SROOT_DIR = "$REG_DIR/server_root";
29 my $DATA_DIR = "$SROOT_DIR/data";
30 my $TEMP_DIR = "$SROOT_DIR/tmp";
31 my $UPLOAD_DIR = "$SROOT_DIR/upload";
32 my $CONF_DIR = "$SROOT_DIR/conf";
33 my $MODULES_DIR = q(@APXS_LIBEXECDIR@);
34 my $FILES_DIR = "$SROOT_DIR/logs";
35 my $PID_FILE = "$FILES_DIR/httpd.pid";
36 my $HTTPD = q(@APXS_HTTPD@);
42 my $UA_NAME = "ModSecurity Regression Tests/1.2.3";
43 my $UA = LWP::UserAgent->new;
46 # Hack for testing the script w/o configure
47 if ($HTTPD eq "\@APXS_HTTPD\@") {
48 $HTTPD = "/usr/local/apache2/bin/httpd";
49 $MODULES_DIR = "/usr/local/apache2/modules";
52 $SIG{TERM} = $SIG{INT} = \&handle_interrupt;
55 getopts('A:E:D:C:T:H:a:p:dvh', \%opt);
58 $Data::Dumper::Indent = 1;
59 $Data::Dumper::Terse = 1;
60 $Data::Dumper::Pad = "";
61 $Data::Dumper::Quotekeys = 0;
67 Usage: $SCRIPT [options] [file [N]]
70 -A file Specify ModSecurity audit log to read.
71 -D file Specify ModSecurity debug log to read.
72 -E file Specify Apache httpd error log to read.
73 -C file Specify Apache httpd base conf file to generate/reload.
74 -H path Specify Apache httpd htdocs path.
75 -S path Specify Apache httpd server root path.
76 -a file Specify Apache httpd binary (default: httpd)
77 -p port Specify Apache httpd port (default: 8088)
78 -v Enable verbose output (details on failure).
79 -d Enable debugging output.
89 ### Check httpd binary
90 if (defined $opt{a}) {
96 usage("Invalid Apache startup script: $HTTPD\n") unless (-e $HTTPD);
99 $opt{A} = "$FILES_DIR/modsec_audit.log" unless (defined $opt{A});
100 $opt{D} = "$FILES_DIR/modsec_debug.log" unless (defined $opt{D});
101 $opt{E} = "$FILES_DIR/error.log" unless (defined $opt{E});
102 $opt{C} = "$CONF_DIR/httpd.conf" unless (defined $opt{C});
103 $opt{H} = "$SROOT_DIR/htdocs" unless (defined $opt{H});
104 $opt{p} = 8088 unless (defined $opt{p});
105 $opt{v} = 1 if ($opt{d});
107 unless (defined $opt{S}) {
108 my $httpd_root = `$HTTPD -V`;
109 ($opt{S} = $httpd_root) =~ s/.*-D HTTPD_ROOT="([^"]*)".*/$1/sm;
114 SERVER_ROOT => $opt{S},
115 SERVER_PORT => $opt{p},
116 SERVER_NAME => "localhost",
117 TEST_SERVER_ROOT => $SROOT_DIR,
118 DATA_DIR => $DATA_DIR,
119 TEMP_DIR => $TEMP_DIR,
120 UPLOAD_DIR => $UPLOAD_DIR,
121 CONF_DIR => $CONF_DIR,
122 MODULES_DIR => $MODULES_DIR,
123 LOGS_DIR => $FILES_DIR,
124 SCRIPT_DIR => $SCRIPT_DIR,
125 REGRESSION_DIR => $REG_DIR,
126 DIST_ROOT => File::Spec->rel2abs(dirname("$SCRIPT_DIR/../../..")),
127 AUDIT_LOG => $opt{A},
128 DEBUG_LOG => $opt{D},
129 ERROR_LOG => $opt{E},
130 HTTPD_CONF => $opt{C},
132 USER_AGENT => $UA_NAME,
135 #dbg("OPTIONS: ", \%opt);
137 if (-e "$PID_FILE") {
138 msg("Shutting down previous instance: $PID_FILE");
142 if (defined $ARGV[0]) {
143 runfile(dirname($ARGV[0]), basename($ARGV[0]), $ARGV[1]);
147 for my $type (@TYPES) {
148 my $dir = "$SCRIPT_DIR/regression/$type";
152 opendir(DIR, "$dir") or quit(1, "Failed to open \"$dir\": $!");
153 @cfg = grep { /\.t$/ && -f "$dir/$_" } readdir(DIR);
156 for my $cfg (sort @cfg) {
164 my($dir, $cfg, $testnum) = @_;
165 my $fn = "$dir/$cfg";
174 open(CFG, "<$fn") or quit(1, "Failed to open \"$fn\": $!");
177 $edata = q/@C = (/ . join("", @data) . q/)/;
179 quit(1, "Failed to read test data \"$cfg\": $@") if ($@);
182 msg("\nNo tests defined for $fn");
186 msg("\nLoaded ".@C." tests from $fn");
189 next if (defined $testnum and $n != $testnum);
193 my $id = sprintf("%3d", $n);
198 # Startup httpd with optionally included conf.
199 if (exists $t{conf} and defined $t{conf}) {
200 $conf_fn = sprintf "%s/%s_%s_%06d.conf",
201 $CONF_DIR, $t{type}, $cfg, $n;
202 #dbg("Writing test config to: $conf_fn");
203 open(CONF, ">$conf_fn") or die "Failed to open conf \"$conf_fn\": $!\n";
204 print CONF (ref $t{conf} eq "CODE" ? eval { &{$t{conf}} } : $t{conf});
207 $httpd_up = httpd_start(\%t, "Include $conf_fn") ? 0 : 1;
210 $httpd_up = httpd_start(\%t) ? 0 : 1;
213 # Run any prerun setup
214 if ($rc == 0 and exists $t{prerun} and defined $t{prerun}) {
215 vrb("Executing perl prerun...");
217 vrb("Perl prerun returned: $rc");
221 # Perform the request and check response
222 if (exists $t{request}) {
223 my $resp = do_request($t{request});
225 msg("invalid response");
226 vrb("RESPONSE: ", $resp);
230 for my $key (keys %{ $t{match_response} || {}}) {
231 my($neg,$mtype) = ($key =~ m/^(-?)(.*)$/);
232 my $m = $t{match_response}{$key};
233 my $match = match_response($mtype, $resp, $m);
234 if ($neg and defined $match) {
236 msg("response $mtype matched: $m");
240 elsif (!$neg and !defined $match) {
242 msg("response $mtype failed to match: $m");
250 # Run any arbitrary perl tests
251 if ($rc == 0 and exists $t{test} and defined $t{test}) {
252 dbg("Executing perl test(s)...");
253 $rc = eval { &{$t{test}} };
255 msg("Error running test: $@");
258 dbg("Perl tests returned: $rc");
261 # Search for all log matches
262 if ($rc == 0 and exists $t{match_log} and defined $t{match_log}) {
263 for my $key (keys %{ $t{match_log} || {}}) {
264 my($neg,$mtype) = ($key =~ m/^(-?)(.*)$/);
265 my $m = $t{match_log}{$key};
266 my $match = match_log($mtype, @{$m || []});
267 if ($neg and defined $match) {
269 msg("$mtype log matched: $m->[0]");
272 elsif (!$neg and !defined $match) {
274 msg("$mtype log failed to match: $m->[0]");
280 # Search for all file matches
281 if ($rc == 0 and exists $t{match_file} and defined $t{match_file}) {
282 sleep 1; # Make sure the file exists
283 for my $key (keys %{ $t{match_file} || {}}) {
284 my($neg,$fn) = ($key =~ m/^(-?)(.*)$/);
285 my $m = $t{match_file}{$key};
286 my $match = match_file($fn, $m);
287 if ($neg and defined $match) {
289 msg("$fn file matched: $m");
292 elsif (!$neg and !defined $match) {
294 msg("$fn file failed match: $m");
301 msg("Failed to start httpd.");
309 vrb("Test Config: $conf_fn");
310 vrb("Debug Log: $FILE{debug}{fn}");
311 dbg(escape("$FILE{debug}{buf}"));
312 vrb("Error Log: $FILE{error}{fn}");
313 dbg(escape("$FILE{error}{buf}"));
316 msg(sprintf("%s) %s%s: %s%s", $id, $t{type}, (exists($t{comment}) ? " - $t{comment}" : ""), ($rc ? "failed" : "passed"), ((defined($out) && $out ne "")? " ($out)" : "")));
319 $httpd_up = httpd_stop(\%t) ? 0 : 1;
324 $TOTAL += $testnum ? 1 : $n;
327 msg(sprintf("Passed: %2d; Failed: %2d", $pass, $testnum ? (1 - $pass) : ($n - $pass)));
330 # Take out any indenting and translate LF -> CRLF
331 sub normalize_raw_request_data {
334 # Allow for indenting in test file
335 $r =~ s/^[ \t]*\x0d?\x0a//s;
336 my($indention) = ($r =~ m/^([ \t]*)/s); # indention taken from first line
337 $r =~ s/^$indention//mg;
338 $r =~ s/(\x0d?\x0a)[ \t]+$/$1/s;
340 # Translate LF to CRLF
341 $r =~ s/^\x0a/\x0d\x0a/mg;
342 $r =~ s/([^\x0d])\x0a/$1\x0d\x0a/mg;
348 my $sock = new IO::Socket::INET(
350 PeerAddr => "localhost",
352 ) or msg("Failed to connect to localhost:$opt{p}: $@");
353 return unless ($sock);
355 # Join togeather the request
356 my $r = join("", @_);
367 return HTTP::Response->parse(join("", @resp));
373 # Allow test to execute code
374 if (ref $r eq "CODE") {
376 msg("$@") unless (defined $r);
379 if (ref $r eq "HTTP::Request") {
380 my $resp = $UA->request($r);
381 dbg($resp->request()->as_string()) if ($opt{d});
385 return do_raw_request($r);
393 my($name, $resp, $re) = @_;
395 msg("Warning: Empty regular expression.") if (!defined $re or $re eq "");
397 if ($name eq "status") {
398 return $& if ($resp->code =~ m/$re/);
400 elsif ($name eq "content") {
401 return $& if ($resp->content =~ m/$re/m);
403 elsif ($name eq "raw") {
404 return $& if ($resp->as_string =~ m/$re/m);
411 my($name, $timeout, $graph) = @_;
412 return match_log($name, undef, $timeout, $graph);
416 my($name, $re, $timeout, $graph) = @_;
417 my $t0 = gettimeofday;
418 my($fh,$rbuf) = ($FILE{$name}{fd}, \$FILE{$name}{buf});
419 my $n = length($$rbuf);
422 unless (defined $fh) {
423 msg("Error: File \"$name\" is not opened for matching.");
427 $timeout = 0 unless (defined $timeout);
433 my $nbytes = $fh->sysread($$rbuf, $BUFSIZ, $n);
434 if (!defined($nbytes)) {
435 msg("Error: Could not read \"$name\" log: $!");
438 elsif (!defined($re) and $nbytes == 0) {
442 # Remove APR pool debugging
443 $$rbuf =~ s/POOL DEBUG:[^\n]+PALLOC[^\n]+\n//sg;
447 #dbg("Match \"$re\" in $name \"$$rbuf\" ($n)");
448 if ($$rbuf =~ m/$re/m) {
452 # TODO: Use select()/poll()
453 sleep 0.1 unless ($nbytes == $BUFSIZ);
454 if ($graph and $opt{d}) {
459 print STDERR $graph if ($graphed == 1);
463 } while (gettimeofday - $t0 < $timeout);
465 print STDERR "\n" if ($graphed);
471 my($neg,$fn) = ($_[0] =~ m/^(-?)(.*)$/);
472 unless (exists $FILE{$fn}) {
474 $FILE{$fn}{fn} = $fn;
475 $FILE{$fn}{fd} = new FileHandle($fn, O_RDONLY) or die "$!\n";
476 $FILE{$fn}{fd}->blocking(0);
477 $FILE{$fn}{buf} = "";
480 msg("Warning: Failed to open file \"$fn\": $@");
484 return match_log($_[0], $_[1]); # timeout makes no sense
489 return $s unless ($s =~ m|[^\w!%+,\-./:@^]|);
490 $s =~ s/(['\\])/\\$1/g;
496 for my $c (split(//, $_[0])) {
498 push @new, ((($oc >= 0x20 and $oc <= 0x7e) or $oc == 0x0a or $oc == 0x0d) ? $c : sprintf("\\x%02x", ord($c)));
504 return unless(@_ and $opt{d});
505 my $out = join "", map {
506 (ref $_ ne "" ? Dumper($_) : $_)
508 $out =~ s/^/DBG: /mg;
509 print STDOUT "$out\n";
513 return unless(@_ and $opt{v});
519 my $out = join "", map {
520 (ref $_ ne "" ? Dumper($_) : $_)
522 print STDOUT "$out\n";
525 sub handle_interrupt {
526 $SIG{TERM} = $SIG{INT} = \&handle_interrupt;
528 msg("Interrupted via SIG$_[0]. Shutting down tests...");
536 $ec = 0 unless (defined $_[0]);
538 msg("$msg") if (defined $msg);
544 if ($PASSED != $TOTAL) {
545 quit(1, "\n$PASSED/$TOTAL tests passed.");
548 quit(0, "\nAll tests passed ($TOTAL).");
558 (map { (-c => $_) } ("Listen localhost:$opt{p}", @_)),
563 my $httpd_pid = open3(undef, $httpd_out, undef, @p) or quit(1);
564 my $out = join("\\n", grep(!/POOL DEBUG/, (<$httpd_out>)));
566 waitpid($httpd_pid, 0);
569 if ( WIFEXITED($rc) ) {
570 $rc = WEXITSTATUS($rc);
571 vrb("Httpd start returned with $rc.") if ($rc);
573 elsif( WIFSIGNALED($rc) ) {
574 msg("Httpd start failed with signal " . WTERMSIG($rc) . ".");
578 msg("Httpd start failed with unknown error.");
582 if (defined $out and $out ne "") {
583 vrb(join(" ", map { quote_shell($_) } @p));
584 msg("Httpd start failed with error messages:\n$out");
588 # Look for startup msg
589 unless (defined match_log("error", qr/resuming normal operations/, 60, "Waiting on httpd to start: ")) {
590 vrb(join(" ", map { quote_shell($_) } @p));
591 vrb(match_log("error", qr/(^.*ModSecurity: .*)/sm, 10));
592 msg("Httpd server failed to start.");
605 (map { (-c => $_) } ("Listen localhost:$opt{p}", @_)),
610 my $httpd_pid = open3(undef, $httpd_out, undef, @p) or quit(1);
611 my $out = join("\\n", grep(!/POOL DEBUG/, (<$httpd_out>)));
613 waitpid($httpd_pid, 0);
615 if (defined $out and $out ne "") {
616 msg("Httpd stop failed with error messages:\n$out");
621 if ( WIFEXITED($rc) ) {
622 $rc = WEXITSTATUS($rc);
623 vrb("Httpd stop returned with $rc.") if ($rc);
625 elsif( WIFSIGNALED($rc) ) {
626 msg("Httpd stop failed with signal " . WTERMSIG($rc) . ".");
630 msg("Httpd stop failed with unknown error.");
634 # Look for startup msg
635 unless (defined match_log("error", qr/caught SIG[A-Z]+, shutting down/, 60, "Waiting on httpd to stop: ")) {
636 vrb(join(" ", map { quote_shell($_) } @p));
637 msg("Httpd server failed to shutdown.");
654 (map { (-c => $_) } ("Listen localhost:$opt{p}", @_)),
659 my $httpd_pid = open3(undef, $httpd_out, undef, @p) or quit(1);
660 my $out = join("\\n", grep(!/POOL DEBUG/, (<$httpd_out>)));
662 waitpid($httpd_pid, 0);
664 if (defined $out and $out ne "") {
665 msg("Httpd reload failed with error messages:\n$out");
670 if ( WIFEXITED($rc) ) {
671 $rc = WEXITSTATUS($rc);
672 vrb("Httpd reload returned with $rc.") if ($rc);
674 elsif( WIFSIGNALED($rc) ) {
675 msg("Httpd reload failed with signal " . WTERMSIG($rc) . ".");
679 msg("Httpd reload failed with unknown error.");
683 # Look for startup msg
684 unless (defined match_log("error", qr/resuming normal operations/, 60, "Waiting on httpd to restart: ")) {
685 vrb(join(" ", map { quote_shell($_) } @p));
686 msg("Httpd server failed to reload.");
697 for my $key (keys %FILE) {
698 if (exists $FILE{$key}{fd} and defined $FILE{$key}{fd}) {
699 $FILE{$key}{fd}->close();
706 $FILE{error}{fn} = $opt{E};
707 $FILE{error}{fd} = new FileHandle($opt{E}, O_RDWR|O_CREAT) or die "$!\n";
708 $FILE{error}{fd}->blocking(0);
709 $FILE{error}{fd}->sysseek(0, 2);
710 $FILE{error}{buf} = "";
713 msg("Warning: Failed to open file \"$opt{E}\": $@");
719 $FILE{audit}{fn} = $opt{A};
720 $FILE{audit}{fd} = new FileHandle($opt{A}, O_RDWR|O_CREAT) or die "$!\n";
721 $FILE{audit}{fd}->blocking(0);
722 $FILE{audit}{fd}->sysseek(0, 2);
723 $FILE{audit}{buf} = "";
726 msg("Warning: Failed to open file \"$opt{A}\": $@");
732 $FILE{debug}{fn} = $opt{D};
733 $FILE{debug}{fd} = new FileHandle($opt{D}, O_RDWR|O_CREAT) or die "$!\n";
734 $FILE{debug}{fd}->blocking(0);
735 $FILE{debug}{fd}->sysseek(0, 2);
736 $FILE{debug}{buf} = "";
739 msg("Warning: Failed to open file \"$opt{D}\": $@");
743 # Any extras listed in "match_log"
744 if ($t and exists $t->{match_log}) {
745 for my $k (keys %{ $t->{match_log} || {} }) {
746 my($neg,$fn) = ($k =~ m/^(-?)(.*)$/);
747 next if (!$fn or exists $FILE{$fn});
749 $FILE{$fn}{fn} = $fn;
750 $FILE{$fn}{fd} = new FileHandle($fn, O_RDWR|O_CREAT) or die "$!\n";
751 $FILE{$fn}{fd}->blocking(0);
752 $FILE{$fn}{fd}->sysseek(0, 2);
753 $FILE{$fn}{buf} = "";
756 msg("Warning: Failed to open file \"$fn\": $@");
764 my($data, $size) = @_;
765 $size = 128 unless ($size);
769 my $bytes = length($data);
770 while ($bytes >= $size) {
771 $chunked .= sprintf "%x\x0d\x0a%s\x0d\x0a", $size, substr($data, $n, $size);
776 $chunked .= sprintf "%x\x0d\x0a%s\x0d\x0a", $bytes, substr($data, $n, $bytes);
778 $chunked .= "0\x0d\x0a\x0d\x0a"