File Coverage

blib/lib/WWW/Wappalyzer.pm
Criterion Covered Total %
statement 168 168 100.0
branch 75 86 87.2
condition 32 35 91.4
subroutine 16 17 94.1
pod 3 3 100.0
total 294 309 95.1


line stmt bran cond sub pod time code
1             package WWW::Wappalyzer;
2              
3 2     2   124365 use 5.006;
  2         21  
4 2     2   12 use strict;
  2         5  
  2         67  
5 2     2   13 use warnings;
  2         5  
  2         70  
6              
7 2     2   21 use base qw ( Exporter );
  2         4  
  2         310  
8             our @EXPORT_OK = qw( detect get_categories add_clues_file );
9              
10 2     2   963 use lib::abs;
  2         5498  
  2         15  
11 2     2   1750 use JSON qw();
  2         25497  
  2         62  
12 2     2   1107 use Regexp::Parser;
  2         101028  
  2         2923  
13              
14             my %_categories;
15             my @_clues_file_list = ( lib::abs::path( './apps.json' ) );
16              
17             # List of multi per web page application categories
18             my %MULTIPLE_APP_CATS = map { $_ => 1 } qw(
19             widgets analytics javascript-frameworks video-players
20             font-scripts miscellaneous advertising-networks payment-processors
21             );
22              
23             =head1 NAME
24              
25             WWW::Wappalyzer - Perl port of Wappalyzer (http://wappalyzer.com)
26              
27             =head1 DESCRIPTION
28              
29             Uncovers the technologies used on websites: detects content management systems, web shops,
30             web servers, JavaScript frameworks, analytics tools and many more.
31              
32             Lacks 'version', 'implies', 'excludes' support of original Wappalyzer in favour of speed.
33              
34             Clues: L
35              
36             More info on Wappalyzer: L
37              
38             =cut
39              
40             our $VERSION = '0.21';
41              
42             =head1 SYNOPSIS
43              
44             use WWW::Wappalyzer;
45             use LWP::UserAgent;
46             use List::Util 'pairmap';
47              
48             my $response = LWP::UserAgent->new->get( 'http://www.drupal.org' );
49             my %detected = WWW::Wappalyzer::detect(
50             html => $response->decoded_content,
51             headers => { pairmap { $a => [ $response->headers->header($a) ] } $response->headers->flatten },
52             );
53              
54             # %detected = (
55             # 'web-servers' => [ 'Apache' ],
56             # 'cms' => [ 'Drupal' ],
57             # 'cache-tools' => [ 'Varnish' ],
58             # 'analytics' => [ 'Google Analytics' ],
59             # 'operating-systems' => [ 'CentOS' ]
60             # );
61              
62             =head1 EXPORT
63              
64             None by default.
65              
66             =head1 SUBROUTINES/METHODS
67              
68             =head2 detect
69              
70             my %detected = detect( %params )
71              
72             Tries to detect CMS, framework, etc for given html code, http headers, url.
73              
74             Available parameters:
75              
76             html - HTML code of web page.
77              
78             headers - Hash ref to http headers list. The value may be a plain string or a array ref
79             of strings for a multi-valued field.
80              
81             url - URL of web page.
82              
83             cats - Array ref to a list of trying categories, defaults to all categories;
84             Less cats => less cpu usage.
85              
86             Returns the hash of detected applications by category:
87              
88             (
89             cms => [ 'Joomla' ],
90             'javascript-frameworks' => [ 'jQuery', 'jQuery UI' ],
91             )
92              
93             =cut
94              
95             sub detect {
96 13     13 1 8523 my %params = @_;
97              
98 13 50 100     58 return () unless $params{html} || $params{headers} || $params{url};
      66        
99            
100             # search will be case insensitive
101 13 100       40 if ( $params{html} ) {
102 9         273 $params{html} = lc $params{html};
103             }
104              
105 13 100       4531 if ( $params{url} ) {
106 1         4 $params{url} = lc $params{url};
107             }
108              
109 13         22 my $headers_ref;
110 13 100       39 if ( $params{headers} ) {
111 9 100       40 die 'Bad headers param' unless ref $params{headers} eq 'HASH';
112              
113             # Make all headers lowercase and array ref valued
114 8         15 $headers_ref = {};
115 8         14 while ( my ( $header, $header_vals_ref ) = each %{ $params{headers} } ) {
  17         65  
116 9 100 66     30 unless ( ref $header_vals_ref ) {
117 7         14 $header_vals_ref = [ $header_vals_ref ];
118             }
119             elsif ( ref $header_vals_ref ne 'ARRAY' ) {
120             next;
121             }
122              
123 8         19 $headers_ref->{ lc $header } = [ map { lc } @$header_vals_ref ];
  10         45  
124             }
125             }
126              
127             # Lazy load and process clues from JSON file
128 12 100       35 _load_categories() unless scalar keys %_categories;
129              
130             my @cats = $params{cats} && ( ref( $params{cats} ) || '' ) eq 'ARRAY'
131 12 100 66     58 ? @{ $params{cats} } : get_categories();
  2         5  
132              
133 12         24 my %detected;
134             my %tried_multi_cat_apps;
135 12         22 for my $cat ( @cats ) {
136 519 50       1170 my $apps_ref = $_categories{ $cat } or die "Unknown category $cat";
137              
138             APP:
139 519         803 for my $app_ref ( @$apps_ref ) {
140              
141 8938         8907 my $detected;
142              
143             # Some speed optimizations
144 8938 100 100     26110 if ( @cats > 1 && $app_ref->{multi_cat}
      100        
145             && exists $tried_multi_cat_apps{ $app_ref->{name} }
146             ) {
147 739         1019 $detected = $tried_multi_cat_apps{ $app_ref->{name} };
148             }
149             else {
150             # Try regexes...
151 8199         8328 my $confidence = 0;
152              
153 8199 100 100     15697 if ( defined $headers_ref && exists $app_ref->{headers_rules} ) {
154 1872         1848 my %headers_rules = %{ $app_ref->{headers_rules} };
  1872         6196  
155              
156             HEADER_RULE:
157 1872         3723 while ( my ( $header, $rule ) = each %headers_rules ) {
158 2241 100       5128 my $header_vals_ref = $headers_ref->{ $header } or next;
159              
160 550         725 for my $header_val ( @$header_vals_ref ) {
161 684 100       2985 if ( $header_val =~ m/$rule->{re}/ ) {
162 8         25 $confidence += $rule->{confidence};
163 8 50       24 if ( $confidence >= 100 ) {
164 8         13 $detected = 1;
165 8         21 last HEADER_RULE;
166             }
167             }
168             }
169             }
170             }
171              
172 8199 100       10751 unless ( $detected ) {
173             # try from most to least relevant method
174             RULES:
175 8191         8881 for my $rule_type ( qw( html url ) ) {
176 16376         19704 my $rule_name = $rule_type. '_rules';
177 16376 100 100     31033 if ( defined $params{ $rule_type } && exists $app_ref->{ $rule_name } ) {
178 3924         3844 for my $rule ( @{ $app_ref->{ $rule_name } } ) {
  3924         7649  
179 4060 100       34253 if ( $params{ $rule_type } =~ m/$rule->{re}/ ) {
180 9         23 $confidence += $rule->{confidence};
181 9 100       22 if ( $confidence >= 100 ) {
182 7         11 $detected = 1;
183 7         18 last RULES;
184             }
185             }
186             }
187             }
188             }
189             }
190              
191             # Some speed optimizations
192 8199 100 100     18845 if ( @cats > 1 && $app_ref->{multi_cat} ) {
193 650         1033 $tried_multi_cat_apps{ $app_ref->{name} } = $detected;
194             }
195             }
196              
197 8938 100       15593 next unless $detected;
198              
199             # Detected!
200 15         24 push @{ $detected{ $cat } }, $app_ref->{name};
  15         56  
201              
202 15 100       87 last APP unless $MULTIPLE_APP_CATS{ $cat };
203             }
204             }
205              
206 12         392 return %detected;
207             }
208              
209             =head2 get_categories
210              
211             my @cats = get_categories()
212              
213             Returns the array of all application categories.
214              
215             =cut
216              
217             sub get_categories {
218             # Lazy load and process clues from JSON files
219 11 100   11 1 129 _load_categories() unless scalar keys %_categories;
220              
221 11         204 return keys %_categories;
222             }
223              
224             # Loads and processes clues from JSON files
225             sub _load_categories {
226              
227 2     2   8 for my $clue_file ( @_clues_file_list ) {
228 3 50       183 open my $fh, '<', $clue_file
229             or die "Can not read clues file $clue_file.";
230              
231 3         17 local $/ = undef;
232 3         2223 my $json = <$fh>;
233 3         44 close $fh;
234              
235             # Replace html entities with oridinary symbols
236 3         853 $json =~ s{>}{>}xig;
237 3         795 $json =~ s{<}{<}xig;
238              
239 3         8 my $cfg_ref = eval { JSON::decode_json( $json ) };
  3         4812  
240              
241 3 50       19 die "Can't parse clue file $clue_file: $@" if $@;
242              
243             my $cats_ref = $cfg_ref->{categories}
244 3 50       14 or die "Broken clues file $clue_file. Can not find categories.";
245              
246             my $apps_ref = $cfg_ref->{apps}
247 3 50       11 or die "Broken clues file $clue_file. Can not find applications.";
248              
249             # Process apps
250 3         18 while ( my ( $app, $app_ref ) = each %$apps_ref ) {
251              
252 1696 50       3638 my $new_app_ref = _process_app_clues( $app, $app_ref ) or next;
253              
254 1696 50       2314 my @cats = @{ $app_ref->{cats} } or next;
  1696         6543  
255              
256 1696 100       4069 $new_app_ref->{multi_cat} = 1 if @cats > 1;
257              
258 1696         3215 for my $cat_id ( @cats ) {
259 1856 50       6545 my $cat = $cats_ref->{ $cat_id } or next;
260              
261 1856         2324 push @{ $_categories{ $cat } }, $new_app_ref;
  1856         15994  
262             }
263             }
264             }
265             }
266              
267             # Process clues of given app
268             sub _process_app_clues {
269 1696     1696   2950 my ( $app, $app_ref ) = @_;
270              
271 1696         4500 my $new_app_ref = { name => $app };
272              
273 1696         2867 my @fields = grep { exists $app_ref->{$_} } qw( script html meta headers url );
  8480         15659  
274 1696         2071 my @html_rules;
275             # Precompile regexps
276 1696         2871 for my $field ( @fields ) {
277 2114         4904 my $rule_ref = $app_ref->{ $field };
278             my @rules_list = !ref $rule_ref ? _parse_rule( $rule_ref )
279 2114 100       7172 : ref $rule_ref eq 'ARRAY' ? ( map { _parse_rule( $_ ) } @$rule_ref )
  186 100       460  
280             : () ;
281              
282 2114 100       8178 if ( $field eq 'html' ) {
    100          
    100          
    100          
    50          
283 523         1195 push @html_rules, map { $_->{re} = qr/(?-x:$_->{re})/; $_ } @rules_list;
  567         20979  
  567         2926  
284             }
285             elsif ( $field eq 'script' ) {
286             push @html_rules,
287             map {
288 547         1223 $_->{re} = qr/
  599         25087  
289             < \s* script [^>]+ src \s* = \s* ["'] [^"']* (?-x:$_->{re}) [^"']* ["']
290             /x;
291 599         3263 $_
292             } @rules_list;
293             }
294             elsif ( $field eq 'url' ) {
295 72         164 my @url_rules = map { $_->{re} = qr/(?-x:$_->{re})/; $_ } @rules_list;
  72         1584  
  72         296  
296 72         224 $new_app_ref->{url_rules} = _optimize_rules( \@url_rules );
297             }
298             elsif ( $field eq 'meta' ) {
299 365         1430 for my $key ( keys %$rule_ref ) {
300 397         848 my $lc_key = lc $key;
301 397         4155 my $name_re = qr/ name \s* = \s* ["']? $lc_key ["']? /x;
302 397         1245 my $rule = _parse_rule( $rule_ref->{$key} );
303 397         9466 $rule->{re} = qr/$rule->{re}/;
304 397         12359 my $content_re = qr/ content \s* = \s* ["'] [^"']* (?-x:$rule->{re}) [^"']* ["'] /x;
305              
306 397         14427 $rule->{re} = qr/
307             < \s* meta \s+
308             (?:
309             (?: $name_re \s+ $content_re )
310             # | (?: $content_re \s+ $name_re ) # hangs sometimes
311             )
312             /x;
313            
314 397         2799 push @html_rules, $rule;
315             }
316             }
317             elsif ( $field eq 'headers' ) {
318 607         2031 for my $key ( keys %$rule_ref ) {
319 715         1719 my $rule = _parse_rule( $rule_ref->{$key} );
320 715         17195 $rule->{re} = qr/$rule->{re}/;
321 715         4474 $new_app_ref->{headers_rules}{ lc $key } = $rule;
322             }
323             }
324             }
325              
326 1696 100       4493 if ( @html_rules ) {
327 1121         3251 $new_app_ref->{html_rules} = _optimize_rules( \@html_rules );
328             }
329              
330 1696         7204 return $new_app_ref;
331             }
332              
333             # separate regexp and other optional parameters from the rule
334             sub _parse_rule {
335 2350     2350   4579 my ( $rule ) = @_;
336            
337 2350         8518 my ( $re, @params ) = split /\\;/, $rule;
338            
339 2350         3450 my $confidence;
340 2350         4295 for my $param ( @params ) {
341 628 100       1994 if ( ( $confidence ) = $param =~ /^\s*confidence\s*:\s*(\d+)\s*$/ ) {
342             # supports only confidence for now
343 58         119 last;
344             }
345             }
346            
347             return {
348 2350 100 100     6104 re => _escape_re( defined( $re ) ? $re : '' ),
349             confidence => $confidence || 100,
350             };
351             }
352              
353             # Escape special symbols in regexp string of config file
354             sub _escape_re {
355 2350     2350   3597 my ( $re ) = @_;
356            
357             # Escape { } braces
358             #$re =~ s/ ([{}]) /[$1]/xig;
359              
360             # Escape [^]
361 2350         5643 $re =~ s{\Q[^]\E}{[\\^]}ig;
362              
363             # Escape \\1
364 2350         3481 $re =~ s{\Q\1\E}{\\\\1}ig;
365              
366             # Escape (?!
367 2350         4101 $re =~ s{[(][?][!]}{([?]!}ig;
368            
369             # turn literals in regexp to lowercase to make case insensitive search
370             # i flag will be slower because we makes many searches in one text
371 2     2   24 no warnings 'redefine';
  2         3  
  2         574  
372 2350     0   11316 local *Regexp::Parser::warn = sub {}; # it may be too noisy
373            
374 2350         9326 my $parser = Regexp::Parser->new();
375 2350 100       2023525 if ( $parser->regex($re) ) {
376 2264         1913444 $re = '';
377            
378 2264         4932 while ( my $node = $parser->next ) {
379 55013         5077444 my $ref = ref $node;
380 55013 100 100     114333 if ( $ref eq 'Regexp::Parser::exact' || $ref eq 'Regexp::Parser::anyof_char' ) {
381 45005         71795 $re .= lc $node->raw;
382             }
383             else {
384 10008         19799 $re .= $node->raw;
385             }
386             }
387             }
388            
389 2350         167647 return $re;
390             }
391              
392             # If possible combine all rules in one regexp
393             sub _optimize_rules {
394 1193     1193   2241 my ( $rules ) = @_;
395            
396 1193 100 100     4155 if ( @$rules > 1 && @$rules == grep { $_->{confidence} == 100 } @$rules ) {
  784         2344  
397             # can combine only if confidence for each is 100
398 317         526 my $re = join '|', map { $_->{re} } @$rules;
  720         1816  
399             return [{
400 317         22164 re => qr/$re/,
401             confidence => 100,
402             }];
403             }
404            
405 876         2330 return $rules;
406             }
407              
408             =head2 add_clues_file
409              
410             add_clues_file( $filepath )
411              
412             Puts additional clues file to a list of processed clues files.
413             See apps.json as format sample.
414              
415             =cut
416              
417             sub add_clues_file {
418 1     1 1 394 my ( $filepath ) = @_;
419              
420 1         2 push @_clues_file_list, $filepath;
421              
422             # just clear out categories to lazy load later
423 1         3144 %_categories = ();
424             }
425              
426             =head1 AUTHOR
427              
428             Alexander Nalobin, C<< >>
429              
430             =head1 BUGS
431              
432             Please report any bugs or feature requests to C, or through
433             the web interface at L. I will be notified, and then you'll
434             automatically be notified of progress on your bug as I make changes.
435              
436              
437             =head1 SUPPORT
438              
439             You can find documentation for this module with the perldoc command.
440              
441             perldoc WWW::Wappalyzer
442              
443              
444             You can also look for information at:
445              
446             =over 4
447              
448             =item * GitHub
449              
450             L
451              
452             =item * RT: CPAN's request tracker (report bugs here)
453              
454             L
455              
456             =item * AnnoCPAN: Annotated CPAN documentation
457              
458             L
459              
460             =item * CPAN Ratings
461              
462             L
463              
464             =item * Search CPAN
465              
466             L
467              
468             =back
469              
470              
471             =head1 ACKNOWLEDGEMENTS
472              
473              
474             =head1 LICENSE AND COPYRIGHT
475              
476             Copyright 2013-2015 Alexander Nalobin.
477              
478             This program is free software; you can redistribute it and/or modify it
479             under the terms of either: the GNU General Public License as published
480             by the Free Software Foundation; or the Artistic License.
481              
482             See http://dev.perl.org/licenses/ for more information.
483              
484              
485             =cut
486              
487             1; # End of WWW::Wappalyzer