1package HTML::Scrubber; 2 3# ABSTRACT: Perl extension for scrubbing/sanitizing HTML 4 5 6use 5.008; # enforce minimum perl version of 5.8 7use strict; 8use warnings; 9use HTML::Parser 3.47 (); 10use HTML::Entities; 11use Scalar::Util ('weaken'); 12use List::Util 1.33 qw(any); 13 14our ( @_scrub, @_scrub_fh ); 15 16our $VERSION = '0.19'; # VERSION 17our $AUTHORITY = 'cpan:NIGELM'; # AUTHORITY 18 19# my my my my, these here to prevent foolishness like 20# http://perlmonks.org/index.pl?node_id=251127#Stealing+Lexicals 21(@_scrub) = ( \&_scrub, "self, event, tagname, attr, attrseq, text" ); 22(@_scrub_fh) = ( \&_scrub_fh, "self, event, tagname, attr, attrseq, text" ); 23 24 25sub new { 26 my $package = shift; 27 my $p = HTML::Parser->new( 28 api_version => 3, 29 default_h => \@_scrub, 30 marked_sections => 0, 31 strict_comment => 0, 32 unbroken_text => 1, 33 case_sensitive => 0, 34 boolean_attribute_value => undef, 35 empty_element_tags => 1, 36 ); 37 38 my $self = { 39 _p => $p, 40 _rules => { '*' => 0, }, 41 _comment => 0, 42 _process => 0, 43 _r => "", 44 _optimize => 1, 45 _script => 0, 46 _style => 0, 47 }; 48 49 $p->{"\0_s"} = bless $self, $package; 50 weaken( $p->{"\0_s"} ); 51 52 return $self unless @_; 53 54 my (%args) = @_; 55 56 for my $f (qw[ default allow deny rules process comment ]) { 57 next unless exists $args{$f}; 58 if ( ref $args{$f} ) { 59 $self->$f( @{ $args{$f} } ); 60 } 61 else { 62 $self->$f( $args{$f} ); 63 } 64 } 65 66 return $self; 67} 68 69 70sub comment { 71 return $_[0]->{_comment} 72 if @_ == 1; 73 $_[0]->{_comment} = $_[1]; 74 return; 75} 76 77 78sub process { 79 return $_[0]->{_process} 80 if @_ == 1; 81 $_[0]->{_process} = $_[1]; 82 return; 83} 84 85 86sub script { 87 return $_[0]->{_script} 88 if @_ == 1; 89 $_[0]->{_script} = $_[1]; 90 return; 91} 92 93 94sub style { 95 return $_[0]->{_style} 96 if @_ == 1; 97 $_[0]->{_style} = $_[1]; 98 return; 99} 100 101 102sub allow { 103 my $self = shift; 104 for my $k (@_) { 105 $self->{_rules}{ lc $k } = 1; 106 } 107 $self->{_optimize} = 1; # each time a rule changes, reoptimize when parse 108 109 return; 110} 111 112 113sub deny { 114 my $self = shift; 115 116 for my $k (@_) { 117 $self->{_rules}{ lc $k } = 0; 118 } 119 120 $self->{_optimize} = 1; # each time a rule changes, reoptimize when parse 121 122 return; 123} 124 125 126sub rules { 127 my $self = shift; 128 my (%rules) = @_; 129 for my $k ( keys %rules ) { 130 $self->{_rules}{ lc $k } = $rules{$k}; 131 } 132 133 $self->{_optimize} = 1; # each time a rule changes, reoptimize when parse 134 135 return; 136} 137 138 139sub default { 140 return $_[0]->{_rules}{'*'} 141 if @_ == 1; 142 143 $_[0]->{_rules}{'*'} = $_[1] if defined $_[1]; 144 $_[0]->{_rules}{'_'} = $_[2] if defined $_[2] and ref $_[2]; 145 $_[0]->{_optimize} = 1; # each time a rule changes, reoptimize when parse 146 147 return; 148} 149 150 151sub scrub_file { 152 if ( @_ > 2 ) { 153 return unless defined $_[0]->_out( $_[2] ); 154 } 155 else { 156 $_[0]->{_p}->handler( default => @_scrub ); 157 } 158 159 $_[0]->_optimize(); #if $_[0]->{_optimize}; 160 161 $_[0]->{_p}->parse_file( $_[1] ); 162 163 return delete $_[0]->{_r} unless exists $_[0]->{_out}; 164 print { $_[0]->{_out} } $_[0]->{_r} if length $_[0]->{_r}; 165 delete $_[0]->{_out}; 166 return 1; 167} 168 169 170sub scrub { 171 if ( @_ > 2 ) { 172 return unless defined $_[0]->_out( $_[2] ); 173 } 174 else { 175 $_[0]->{_p}->handler( default => @_scrub ); 176 } 177 178 $_[0]->_optimize(); # if $_[0]->{_optimize}; 179 180 $_[0]->{_p}->parse( $_[1] ) if defined( $_[1] ); 181 $_[0]->{_p}->eof(); 182 183 return delete $_[0]->{_r} unless exists $_[0]->{_out}; 184 delete $_[0]->{_out}; 185 return 1; 186} 187 188 189sub _out { 190 my ( $self, $o ) = @_; 191 192 unless ( ref $o and ref \$o ne 'GLOB' ) { 193 open my $F, '>', $o or return; 194 binmode $F; 195 $self->{_out} = $F; 196 } 197 else { 198 $self->{_out} = $o; 199 } 200 201 $self->{_p}->handler( default => @_scrub_fh ); 202 203 return 1; 204} 205 206 207sub _validate { 208 my ( $s, $t, $r, $a, $as ) = @_; 209 return "<$t>" unless %$a; 210 211 $r = $s->{_rules}->{$r}; 212 my %f; 213 214 for my $k ( keys %$a ) { 215 my $check = exists $r->{$k} ? $r->{$k} : exists $r->{'*'} ? $r->{'*'} : next; 216 217 if ( ref $check eq 'CODE' ) { 218 my @v = $check->( $s, $t, $k, $a->{$k}, $a, \%f ); 219 next unless @v; 220 $f{$k} = shift @v; 221 } 222 elsif ( ref $check || length($check) > 1 ) { 223 $f{$k} = $a->{$k} if $a->{$k} =~ m{$check}; 224 } 225 elsif ($check) { 226 $f{$k} = $a->{$k}; 227 } 228 } 229 230 if (%f) { 231 my %seen; 232 return "<$t $r>" 233 if $r = join ' ', map { 234 defined $f{$_} 235 ? qq[$_="] . encode_entities( $f{$_} ) . q["] 236 : $_; # boolean attribute (TODO?) 237 } grep { exists $f{$_} and !$seen{$_}++; } @$as; 238 } 239 240 return "<$t>"; 241} 242 243 244sub _scrub_str { 245 my ( $p, $e, $t, $a, $as, $text ) = @_; 246 247 my $s = $p->{"\0_s"}; 248 my $outstr = ''; 249 250 if ( $e eq 'start' ) { 251 if ( exists $s->{_rules}->{$t} ) # is there a specific rule 252 { 253 if ( ref $s->{_rules}->{$t} ) # is it complicated?(not simple;) 254 { 255 $outstr .= $s->_validate( $t, $t, $a, $as ); 256 } 257 elsif ( $s->{_rules}->{$t} ) # validate using default attribute rule 258 { 259 $outstr .= $s->_validate( $t, '_', $a, $as ); 260 } 261 } 262 elsif ( $s->{_rules}->{'*'} ) # default allow tags 263 { 264 $outstr .= $s->_validate( $t, '_', $a, $as ); 265 } 266 } 267 elsif ( $e eq 'end' ) { 268 269 # empty tags list taken from 270 # https://developer.mozilla.org/en/docs/Glossary/empty_element 271 my @empty_tags = qw(area base br col embed hr img input link meta param source track wbr); 272 return "" if $text ne '' && any { $t eq $_ } @empty_tags; # skip false closing empty tags 273 274 my $place = 0; 275 if ( exists $s->{_rules}->{$t} ) { 276 $place = 1 if $s->{_rules}->{$t}; 277 } 278 elsif ( $s->{_rules}->{'*'} ) { 279 $place = 1; 280 } 281 if ($place) { 282 if ( length $text ) { 283 $outstr .= "</$t>"; 284 } 285 else { 286 substr $s->{_r}, -1, 0, ' /'; 287 } 288 } 289 } 290 elsif ( $e eq 'comment' ) { 291 if ( $s->{_comment} ) { 292 293 # only copy comments through if they are well formed... 294 $outstr .= $text if ( $text =~ m|^<!--.*-->$|ms ); 295 } 296 } 297 elsif ( $e eq 'process' ) { 298 $outstr .= $text if $s->{_process}; 299 } 300 elsif ( $e eq 'text' or $e eq 'default' ) { 301 $text =~ s/</</g; #https://rt.cpan.org/Public/Ticket/Attachment/83958/10332/scrubber.patch 302 $text =~ s/>/>/g; 303 304 $outstr .= $text; 305 } 306 elsif ( $e eq 'start_document' ) { 307 $outstr = ""; 308 } 309 310 return $outstr; 311} 312 313 314sub _scrub_fh { 315 my $self = $_[0]->{"\0_s"}; 316 print { $self->{_out} } $self->{'_r'} if length $self->{_r}; 317 $self->{'_r'} = _scrub_str(@_); 318} 319 320 321sub _scrub { 322 323 $_[0]->{"\0_s"}->{_r} .= _scrub_str(@_); 324} 325 326sub _optimize { 327 my ($self) = @_; 328 329 my (@ignore_elements) = grep { not $self->{"_$_"} } qw(script style); 330 $self->{_p}->ignore_elements(@ignore_elements); # if @ is empty, we reset ;) 331 332 return unless $self->{_optimize}; 333 334 #sub allow 335 # return unless $self->{_optimize}; # till I figure it out (huh) 336 337 if ( $self->{_rules}{'*'} ) { # default allow 338 $self->{_p}->report_tags(); # so clear it 339 } 340 else { 341 342 my (@reports) = 343 grep { # report only tags we want 344 $self->{_rules}{$_} 345 } keys %{ $self->{_rules} }; 346 347 $self->{_p}->report_tags( # default deny, so optimize 348 @reports 349 ) if @reports; 350 } 351 352 # sub deny 353 # return unless $self->{_optimize}; # till I figure it out (huh) 354 my (@ignores) = 355 grep { not $self->{_rules}{$_} } grep { $_ ne '*' } keys %{ $self->{_rules} }; 356 357 $self->{_p}->ignore_tags( # always ignore stuff we don't want 358 @ignores 359 ) if @ignores; 360 361 $self->{_optimize} = 0; 362 return; 363} 364 3651; 366 367#print sprintf q[ '%-12s => %s,], "$_'", $h{$_} for sort keys %h;# perl! 368#perl -ne"chomp;print $_;print qq'\t\t# test ', ++$a if /ok\(/;print $/" test.pl >test2.pl 369#perl -ne"chomp;print $_;if( /ok\(/ ){s/\#test \d+$//;print qq'\t\t# test ', ++$a }print $/" test.pl >test2.pl 370#perl -ne"chomp;if(/ok\(/){s/# test .*$//;print$_,qq'\t\t# test ',++$a}else{print$_}print$/" test.pl >test2.pl 371 372__END__ 373 374=pod 375 376=encoding UTF-8 377 378=head1 NAME 379 380HTML::Scrubber - Perl extension for scrubbing/sanitizing HTML 381 382=head1 VERSION 383 384version 0.19 385 386=for stopwords html cpan callback homepage Perlbrew perltidy repository 387 388=head1 SYNOPSIS 389 390 use HTML::Scrubber; 391 392 my $scrubber = HTML::Scrubber->new( allow => [ qw[ p b i u hr br ] ] ); 393 print $scrubber->scrub('<p><b>bold</b> <em>missing</em></p>'); 394 # output is: <p><b>bold</b> </p> 395 396 # more complex input 397 my $html = q[ 398 <style type="text/css"> BAD { background: #666; color: #666;} </style> 399 <script language="javascript"> alert("Hello, I am EVIL!"); </script> 400 <HR> 401 a => <a href=1>link </a> 402 br => <br> 403 b => <B> bold </B> 404 u => <U> UNDERLINE </U> 405 ]; 406 407 print $scrubber->scrub($html); 408 409 $scrubber->deny( qw[ p b i u hr br ] ); 410 411 print $scrubber->scrub($html); 412 413=head1 DESCRIPTION 414 415If you want to "scrub" or "sanitize" html input in a reliable and flexible 416fashion, then this module is for you. 417 418I wasn't satisfied with L<HTML::Sanitizer> because it is based on 419L<HTML::TreeBuilder>, so I thought I'd write something similar that works 420directly with L<HTML::Parser>. 421 422=head1 METHODS 423 424First a note on documentation: just study the L<EXAMPLE|"EXAMPLE"> below. It's 425all the documentation you could need. 426 427Also, be sure to read all the comments as well as L<How does it work?|"How does 428it work?">. 429 430If you're new to perl, good luck to you. 431 432=head2 new 433 434 my $scrubber = HTML::Scrubber->new( allow => [ qw[ p b i u hr br ] ] ); 435 436Build a new L<HTML::Scrubber>. The arguments are the initial values for the 437following directives:- 438 439=over 4 440 441=item * default 442 443=item * allow 444 445=item * deny 446 447=item * rules 448 449=item * process 450 451=item * comment 452 453=back 454 455=head2 comment 456 457 warn "comments are ", $p->comment ? 'allowed' : 'not allowed'; 458 $p->comment(0); # off by default 459 460=head2 process 461 462 warn "process instructions are ", $p->process ? 'allowed' : 'not allowed'; 463 $p->process(0); # off by default 464 465=head2 script 466 467 warn "script tags (and everything in between) are supressed" 468 if $p->script; # off by default 469 $p->script( 0 || 1 ); 470 471B<**> Please note that this is implemented using L<HTML::Parser>'s 472C<ignore_elements> function, so if C<script> is set to true, all script tags 473encountered will be validated like all other tags. 474 475=head2 style 476 477 warn "style tags (and everything in between) are supressed" 478 if $p->style; # off by default 479 $p->style( 0 || 1 ); 480 481B<**> Please note that this is implemented using L<HTML::Parser>'s 482C<ignore_elements> function, so if C<style> is set to true, all style tags 483encountered will be validated like all other tags. 484 485=head2 allow 486 487 $p->allow(qw[ t a g s ]); 488 489=head2 deny 490 491 $p->deny(qw[ t a g s ]); 492 493=head2 rules 494 495 $p->rules( 496 img => { 497 src => qr{^(?!http://)}i, # only relative image links allowed 498 alt => 1, # alt attribute allowed 499 '*' => 0, # deny all other attributes 500 }, 501 a => { 502 href => sub { ... }, # check or adjust with a callback 503 }, 504 b => 1, 505 ... 506 ); 507 508Updates a set of attribute rules. Each rule can be 1/0, a regular expression or 509a callback. Values longer than 1 char are treated as regexps. The callback is 510called with the following arguments: the current object, tag name, attribute 511name, and attribute value; the callback should return an empty list to drop the 512attribute, C<undef> to keep it without a value, or a new scalar value. 513 514=head2 default 515 516 print "default is ", $p->default(); 517 $p->default(1); # allow tags by default 518 $p->default( 519 undef, # don't change 520 { # default attribute rules 521 '*' => 1, # allow attributes by default 522 } 523 ); 524 525=head2 scrub_file 526 527 $html = $scrubber->scrub_file('foo.html'); ## returns giant string 528 die "Eeek $!" unless defined $html; ## opening foo.html may have failed 529 $scrubber->scrub_file('foo.html', 'new.html') or die "Eeek $!"; 530 $scrubber->scrub_file('foo.html', *STDOUT) 531 or die "Eeek $!" 532 if fileno STDOUT; 533 534=head2 scrub 535 536 print $scrubber->scrub($html); ## returns giant string 537 $scrubber->scrub($html, 'new.html') or die "Eeek $!"; 538 $scrubber->scrub($html', *STDOUT) 539 or die "Eeek $!" 540 if fileno STDOUT; 541 542=for comment _out 543 $scrubber->_out(*STDOUT) if fileno STDOUT; 544 $scrubber->_out('foo.html') or die "eeek $!"; 545 546=for comment _validate 547Uses $self->{_rules} to do attribute validation. 548Takes tag, rule('_' || $tag), attrref. 549 550=for comment _scrub_str 551 552I<default> handler, used by both C<_scrub> and C<_scrub_fh>. Moved all the 553common code (basically all of it) into a single routine for ease of 554maintenance. 555 556=for comment _scrub_fh 557 558I<default> handler, does the scrubbing if we're scrubbing out to a file. Now 559calls C<_scrub_str> and pushes that out to a file. 560 561=for comment _scrub 562 563I<default> handler, does the scrubbing if we're returning a giant string. Now 564calls C<_scrub_str> and appends that to the output string. 565 566=head1 How does it work? 567 568When a tag is encountered, L<HTML::Scrubber> allows/denies the tag using the 569explicit rule if one exists. 570 571If no explicit rule exists, Scrubber applies the default rule. 572 573If an explicit rule exists, but it's a simple rule(1), then the default 574attribute rule is applied. 575 576=head2 EXAMPLE 577 578=for example begin 579 580 #!/usr/bin/perl -w 581 use HTML::Scrubber; 582 use strict; 583 584 my @allow = qw[ br hr b a ]; 585 586 my @rules = ( 587 script => 0, 588 img => { 589 src => qr{^(?!http://)}i, # only relative image links allowed 590 alt => 1, # alt attribute allowed 591 '*' => 0, # deny all other attributes 592 }, 593 ); 594 595 my @default = ( 596 0 => # default rule, deny all tags 597 { 598 '*' => 1, # default rule, allow all attributes 599 'href' => qr{^(?:http|https|ftp)://}i, 600 'src' => qr{^(?:http|https|ftp)://}i, 601 602 # If your perl doesn't have qr 603 # just use a string with length greater than 1 604 'cite' => '(?i-xsm:^(?:http|https|ftp):)', 605 'language' => 0, 606 'name' => 1, # could be sneaky, but hey ;) 607 'onblur' => 0, 608 'onchange' => 0, 609 'onclick' => 0, 610 'ondblclick' => 0, 611 'onerror' => 0, 612 'onfocus' => 0, 613 'onkeydown' => 0, 614 'onkeypress' => 0, 615 'onkeyup' => 0, 616 'onload' => 0, 617 'onmousedown' => 0, 618 'onmousemove' => 0, 619 'onmouseout' => 0, 620 'onmouseover' => 0, 621 'onmouseup' => 0, 622 'onreset' => 0, 623 'onselect' => 0, 624 'onsubmit' => 0, 625 'onunload' => 0, 626 'src' => 0, 627 'type' => 0, 628 } 629 ); 630 631 my $scrubber = HTML::Scrubber->new(); 632 $scrubber->allow(@allow); 633 $scrubber->rules(@rules); # key/value pairs 634 $scrubber->default(@default); 635 $scrubber->comment(1); # 1 allow, 0 deny 636 637 ## preferred way to create the same object 638 $scrubber = HTML::Scrubber->new( 639 allow => \@allow, 640 rules => \@rules, 641 default => \@default, 642 comment => 1, 643 process => 0, 644 ); 645 646 require Data::Dumper, die Data::Dumper::Dumper($scrubber) if @ARGV; 647 648 my $it = q[ 649 <?php echo(" EVIL EVIL EVIL "); ?> <!-- asdf --> 650 <hr> 651 <I FAKE="attribute" > IN ITALICS WITH FAKE="attribute" </I><br> 652 <B> IN BOLD </B><br> 653 <A NAME="evil"> 654 <A HREF="javascript:alert('die die die');">HREF=JAVA <!></A> 655 <br> 656 <A HREF="image/bigone.jpg" ONMOUSEOVER="alert('die die die');"> 657 <IMG SRC="image/smallone.jpg" ALT="ONMOUSEOVER JAVASCRIPT"> 658 </A> 659 </A> <br> 660 ]; 661 662 print "#original text", $/, $it, $/; 663 print 664 "#scrubbed text (default ", $scrubber->default(), # no arguments returns the current value 665 " comment ", $scrubber->comment(), " process ", $scrubber->process(), " )", $/, $scrubber->scrub($it), $/; 666 667 $scrubber->default(1); # allow all tags by default 668 $scrubber->comment(0); # deny comments 669 670 print 671 "#scrubbed text (default ", 672 $scrubber->default(), 673 " comment ", 674 $scrubber->comment(), 675 " process ", 676 $scrubber->process(), 677 " )", $/, 678 $scrubber->scrub($it), 679 $/; 680 681 $scrubber->process(1); # allow process instructions (dangerous) 682 $default[0] = 1; # allow all tags by default 683 $default[1]->{'*'} = 0; # deny all attributes by default 684 $scrubber->default(@default); # set the default again 685 686 print 687 "#scrubbed text (default ", 688 $scrubber->default(), 689 " comment ", 690 $scrubber->comment(), 691 " process ", 692 $scrubber->process(), 693 " )", $/, 694 $scrubber->scrub($it), 695 $/; 696 697=for example end 698 699=head2 FUN 700 701If you have L<Test::Inline> (and you've installed L<HTML::Scrubber>), try 702 703 pod2test Scrubber.pm >scrubber.t 704 perl scrubber.t 705 706=head1 SEE ALSO 707 708L<HTML::Parser>, L<Test::Inline>. 709 710The L<HTML::Sanitizer> module is no longer available on CPAN. 711 712=head1 VERSION REQUIREMENTS 713 714As of version 0.14 I have added a perl minimum version requirement of 5.8. This 715is basically due to failures on the smokers perl 5.6 installations - which 716appears to be down to installation mechanisms and requirements. 717 718Since I don't want to spend the time supporting a version that is so old (and 719may not work for reasons on UTF support etc), I have added a C<use 5.008;> to 720the main module. 721 722If this is problematic I am very willing to accept patches to fix this up, 723although I do not personally see a good reason to support a release that has 724been obsolete for 13 years. 725 726=head1 CONTRIBUTING 727 728If you want to contribute to the development of this module, the code is on 729L<GitHub|http://github.com/nigelm/html-scrubber>. You'll need a perl 730environment with L<Dist::Zilla>, and if you're just getting started, there's 731some documentation on using Vagrant and Perlbrew 732L<here|http://mrcaron.github.io/2015/03/06/Perl-CPAN-Pull-Request.html>. 733 734There is now a C<.perltidyrc> and a C<.tidyallrc> file within the repository 735for the standard perltidy settings used - I will apply these before new 736releases. Please do not let formatting prevent you from sending in patches etc 737- this can be sorted out as part of the release process. Info on C<tidyall> 738can be found at 739L<https://metacpan.org/pod/distribution/Code-TidyAll/bin/tidyall>. 740 741=head1 AUTHORS 742 743=over 4 744 745=item * 746 747Ruslan Zakirov <Ruslan.Zakirov@gmail.com> 748 749=item * 750 751Nigel Metheringham <nigelm@cpan.org> 752 753=item * 754 755D. H. <podmaster@cpan.org> 756 757=back 758 759=head1 COPYRIGHT AND LICENSE 760 761This software is copyright (c) 2018 by Ruslan Zakirov, Nigel Metheringham, 2003-2004 D. H. 762 763This is free software; you can redistribute it and/or modify it under 764the same terms as the Perl 5 programming language system itself. 765 766=for :stopwords cpan testmatrix url annocpan anno bugtracker rt cpants kwalitee diff irc mailto metadata placeholders metacpan 767 768=head1 SUPPORT 769 770=head2 Perldoc 771 772You can find documentation for this module with the perldoc command. 773 774 perldoc HTML::Scrubber 775 776=head2 Websites 777 778The following websites have more information about this module, and may be of help to you. As always, 779in addition to those websites please use your favorite search engine to discover more resources. 780 781=over 4 782 783=item * 784 785MetaCPAN 786 787A modern, open-source CPAN search engine, useful to view POD in HTML format. 788 789L<https://metacpan.org/release/HTML-Scrubber> 790 791=item * 792 793Search CPAN 794 795The default CPAN search engine, useful to view POD in HTML format. 796 797L<http://search.cpan.org/dist/HTML-Scrubber> 798 799=item * 800 801RT: CPAN's Bug Tracker 802 803The RT ( Request Tracker ) website is the default bug/issue tracking system for CPAN. 804 805L<https://rt.cpan.org/Public/Dist/Display.html?Name=HTML-Scrubber> 806 807=item * 808 809AnnoCPAN 810 811The AnnoCPAN is a website that allows community annotations of Perl module documentation. 812 813L<http://annocpan.org/dist/HTML-Scrubber> 814 815=item * 816 817CPAN Ratings 818 819The CPAN Ratings is a website that allows community ratings and reviews of Perl modules. 820 821L<http://cpanratings.perl.org/d/HTML-Scrubber> 822 823=item * 824 825CPANTS 826 827The CPANTS is a website that analyzes the Kwalitee ( code metrics ) of a distribution. 828 829L<http://cpants.cpanauthors.org/dist/HTML-Scrubber> 830 831=item * 832 833CPAN Testers 834 835The CPAN Testers is a network of smoke testers who run automated tests on uploaded CPAN distributions. 836 837L<http://www.cpantesters.org/distro/H/HTML-Scrubber> 838 839=item * 840 841CPAN Testers Matrix 842 843The CPAN Testers Matrix is a website that provides a visual overview of the test results for a distribution on various Perls/platforms. 844 845L<http://matrix.cpantesters.org/?dist=HTML-Scrubber> 846 847=item * 848 849CPAN Testers Dependencies 850 851The CPAN Testers Dependencies is a website that shows a chart of the test results of all dependencies for a distribution. 852 853L<http://deps.cpantesters.org/?module=HTML::Scrubber> 854 855=back 856 857=head2 Bugs / Feature Requests 858 859Please report any bugs or feature requests by email to C<bug-html-scrubber at rt.cpan.org>, or through 860the web interface at L<https://rt.cpan.org/Public/Bug/Report.html?Queue=HTML-Scrubber>. You will be automatically notified of any 861progress on the request by the system. 862 863=head2 Source Code 864 865The code is open to the world, and available for you to hack on. Please feel free to browse it and play 866with it, or whatever. If you want to contribute patches, please send me a diff or prod me to pull 867from your repository :) 868 869L<https://github.com/nigelm/html-scrubber> 870 871 git clone https://github.com/nigelm/html-scrubber.git 872 873=head1 CONTRIBUTORS 874 875=for stopwords Andrei Vereha Lee Johnson Michael Caron Nigel Metheringham Paul Cochrane Ruslan Zakirov Sergey Romanov vagrant 876 877=over 4 878 879=item * 880 881Andrei Vereha <avereha@gmail.com> 882 883=item * 884 885Lee Johnson <lee@givengain.ch> 886 887=item * 888 889Michael Caron <michael.r.caron@gmail.com> 890 891=item * 892 893Michael Caron <mrcaron@users.noreply.github.com> 894 895=item * 896 897Nigel Metheringham <nm9762github@muesli.org.uk> 898 899=item * 900 901Paul Cochrane <paul@liekut.de> 902 903=item * 904 905Ruslan Zakirov <ruz@bestpractical.com> 906 907=item * 908 909Sergey Romanov <complefor@rambler.ru> 910 911=item * 912 913vagrant <vagrant@precise64.(none)> 914 915=back 916 917=cut 918