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/</&lt;/g;    #https://rt.cpan.org/Public/Ticket/Attachment/83958/10332/scrubber.patch
302        $text =~ s/>/&gt;/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 &lt;!&gt;</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